Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts
T
eomsangdonandClaude Opus 5.5 b67dcc804c feat(M02): 흐름도 다이어그램 — 넓힌 타입에 맞춤 · 선 규칙 이름표(CfLink.rule) · 흐름 길(CfStep) · 끊긴 곳(CfBreak) · 고른 선 · 상자는 흐리게 안 함(PLAN 35-9)
- DataFlow 전부 검사 초록 · Diagram 639줄 + Parts 248줄
- ORCA 견본(상자 6 · 선 5 · 규칙 넷): 이름표 「표: 석공 칸」「× 1.3」「지반 = 암일 때」「소수 2 반올림」 선 가운데 0.1 안
  · 온 길 상자 3 · 선 2 · 알약 5 / 가는 길 상자 2 · 선 2 · 알약 3 / 나머지 흐림(색 + 굵기 3.5 + 점선)
  · 끊긴 곳 상자 · 선 · 알약 표시 · 누르면 까닭 · 알약 고름 on.select(f1, 알약) · 선 고름 진하게
  · 가운데 단추 끌기 판 +60,+30(상자 안 움직임) · 왼쪽 빈 곳 끌기 판 그대로 · Space+끌기 판 −50 · 상자 머리 끌기 on.move
  · user-select none · 밝음 · 어두움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 19:49:24 +09:00

248 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_DF_Diagram_Parts.ts
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
type CfLink,
type CfStep,
} from "./M02_DF_Model";
import type { CfBreak } from "./M02_DF_Eval";
import { ruleLabel } from "./M02_DF_Rule";
export const TEXT = {
Title: ["다이어그램", "Diagram"],
K_input: ["입력", "Input"],
K_function: ["함수", "Function"],
K_output: ["출력", "Output"],
AddInput: ["+ 입력", "+ Input"],
AddFunc: ["+ 함수", "+ Function"],
AddOutput: ["+ 출력", "+ Output"],
ZoomIn: ["확대", "Zoom in"],
ZoomOut: ["축소", "Zoom out"],
Fit: ["한눈에", "Fit all"],
Arrange: ["줄 맞추기", "Line up"],
Cancel: ["취소", "Cancel"],
Connect: ["잇기", "Connect"],
Disconnect: ["끊기", "Disconnect"],
Remove: ["상자 지우기", "Remove box"],
AskRemove: [
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
"Remove box “{v}”? Its lines go too",
],
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
NoMatch: [
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
"No pill with that name — drop on a pill",
],
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
From: ["어디서", "From"],
To: ["어디로", "To"],
Value: ["지금 값", "Now"],
PlusIn: [
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
"Drop an output pill here to add an input",
],
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
Hint: [
"상자 머리를 끌어 옮김 · 가운데 단추(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
"Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom",
],
Break: ["끊긴 곳", "Break"],
Back: ["온 길", "Came from"],
Forward: ["가는 길", "Goes to"],
} as const;
export const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
export const KIND_COLOR: Record<CfKind, string> = {
input: "#2a6fb0",
function: "#7a4fb5",
output: "#c2610c",
};
/* ── 맞출 모양 ──────────────────────────────────────────────────────── */
/** 고른 알약 */
export interface DfPillSel {
box: string;
port: string;
dir: "in" | "out";
}
export interface DiagramOn {
/** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */
select(id: string | null, pill?: DfPillSel): void;
move(boxId: string, x: number, y: number): void;
/** 잇기 — 막히면 까닭(쉬운 말) */
link(from: CfPort, to: CfPort): string | null | void;
unlink(linkId: string): void;
add(kind: CfKind, x: number, y: number): void;
remove(boxId: string): void;
}
export interface DiagramOpts {
/** 상자 id → 알약 이름 → 지금 값 */
values?: Record<string, Record<string, unknown>>;
/** 고른 상자 id · 선 id */
selected?: string | null;
/** 고른 알약 */
selectedPill?: DfPillSel | null;
/** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */
labels?: Record<string, string>;
/** 흐름 길 — Model traceBack · traceForward 걸음 */
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
/** 알약 — Model 의 받는 · 내보내는 알약 */
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
return { ins: portsIn(box), outs: portsOut(box) };
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
export function resolveDrop(
lg: CfLogic,
from: CfPort,
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
): { to: CfPort } | { why: string } {
if (!target) return { why: tt("NotIn") };
if (target.box === from.box) return { why: tt("SameBox") };
if (target.dir === "out") return { why: tt("NotIn") };
const box = lg.boxes.find((b) => b.id === target.box);
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
if (box?.kind === "function" && target.name === null)
return { to: { box: box.id, name: from.name } };
const ins = box ? pillsOf(box).ins : [];
if (!box || !ins.length) return { why: tt("NoIn") };
const name =
target.name ??
ins.find((p) => p.name === from.name)?.name ??
(ins.length === 1 ? ins[0].name : null);
if (!name) return { why: tt("NoMatch") };
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
return { to: { box: box.id, name } };
}
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
export function arrange(
lg: CfLogic,
gap = { x: 260, y: 130 },
): Record<string, { x: number; y: number }> {
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
for (let round = 0; round < lg.boxes.length; round++) {
let moved = false;
for (const l of lg.links) {
const d = (depth.get(l.from.box) ?? 0) + 1;
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
depth.set(l.to.box, d);
moved = true;
}
}
if (!moved) break;
}
const rows = new Map<number, number>();
const out: Record<string, { x: number; y: number }> = {};
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
const d = depth.get(b.id) ?? 0;
const r = rows.get(d) ?? 0;
rows.set(d, r + 1);
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
}
return out;
}
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
export const FIT_MIN = 0.9;
export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
}
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
export const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
if (Array.isArray(v)) {
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
}
return String(v).slice(0, 12);
};
/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */
/** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */
export type TraceMark = "back" | "fwd" | "dim" | "";
export function traceOf(
trace: DiagramOpts["trace"],
pick: (steps: CfStep[]) => boolean,
): TraceMark {
if (!trace || (!trace.back?.length && !trace.forward?.length)) return "";
if (trace.back && pick(trace.back)) return "back";
if (trace.forward && pick(trace.forward)) return "fwd";
return "dim";
}
/** 선 위 규칙 글 — Rule 의 ruleLabel · 준비 전이면 같은 꼴로 */
export function linkLabel(link: CfLink): string {
const r = link.rule;
if (!r) return "";
try {
return ruleLabel(r);
} catch {
if (r.kind === "lookup") return `표: ${r.col} 칸`;
if (r.kind === "gate") return `${r.from.name} ${r.op} ${r.value}일 때`;
if (r.kind === "scale")
return r.op === "×" || r.op === "÷"
? `${r.op} ${r.value}`
: `${r.op === "+%" ? "+" : "−"}${r.value}%`;
return `소수 ${r.places} ${r.method}`;
}
}
/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */
export const midOf = (
a: { x: number; y: number },
b: { x: number; y: number },
): { x: number; y: number } => ({
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
});