/* ============================================================================= * M02_DF_Diagram_Parts.ts * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `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, elementPort, ruleSources, } 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"], DepLookup: ["표 조건", "table key"], DepGate: ["조건", "condition"], DepMaterial: ["{f}에 {o}", "{f} {o}"], Rest: ["쉼", "Resting"], Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"], Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"], Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"], Forward: ["가는 길", "Goes to"], } as const; export const tt = (key: keyof typeof TEXT, fill: Record = {}): string => TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); /** 종류별 기본 색(상자마다 바꾼 색이 먼저) */ export const KIND_COLOR: Record = { 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>; /** 고른 상자 id · 선 id */ selected?: string | null; /** 고른 알약 */ selectedPill?: DfPillSel | null; /** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */ labels?: Record; /** 흐름 길 — Model traceBack · traceForward 걸음 */ trace?: { back?: CfStep[]; forward?: CfStep[] } | null; /** 끊긴 곳 — Eval breaks */ breaks?: CfBreak[]; /** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */ readOnly?: boolean; /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ fitKey?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ /** 알약 — 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 { const depth = new Map(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(); const out: Record = {}; 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 const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : ""); /** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 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, }); /* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */ /** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */ export interface DepLine { id: string; from: CfPort; to: { link: string } | { box: string; port: string }; text: string; } export function depLines(lg: CfLogic): DepLine[] { const out: DepLine[] = []; for (const l of lg.links) ruleSources(l.rule).forEach((from, i) => out.push({ id: `dep:${l.id}:${i}`, from, to: { link: l.id }, text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"), }), ); for (const b of lg.boxes) for (const e of b.input?.elements ?? []) if (e.mode === "rule" && e.rule) out.push({ id: `dep:${b.id}:${elementPort(e)}`, from: e.rule.from, to: { box: b.id, port: elementPort(e) }, text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }), }); return out; } /** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */ export function depInTrace(dep: DepLine, steps: CfStep[]): boolean { const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name); const dst = "link" in dep.to ? steps.some((s) => s.link === (dep.to as { link: string }).link) : steps.some((s) => s.box === (dep.to as { box: string }).box); return src && dst; } type Pt = { x: number; y: number }; /** 이름표에 닿는 끝 — 시작이 왼쪽이면 이름표 왼쪽 가장자리 · 오른쪽이면 오른쪽 가장자리(이름표가 없으면 선 가운데) */ export function depEnd(a: Pt | null, tag: Pt & { w: number }): Pt & { side: "left" | "right" } { const fromLeft = !a || a.x <= tag.x; return { x: tag.x + (fromLeft ? -1 : 1) * (tag.w / 2 + 2), y: tag.y, side: fromLeft ? "left" : "right", }; } /** 기댐 선 길 — 알약 오른쪽에서 나가 끝에 닿음 · side = 끝으로 들어가는 쪽(재료 알약은 오른쪽에서) */ export function depPath(a: Pt, b: Pt, side: "left" | "right"): string { const dx = Math.max(40, Math.abs(b.x - a.x) / 2); const bx = side === "left" ? b.x - dx : b.x + dx; return `M${a.x},${a.y} C${a.x + dx},${a.y} ${bx},${b.y} ${b.x},${b.y}`; } /** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */ export const isRest = (k: CfBreak): boolean => k.level === "쉼"; /** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자 * 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */ export function restParts( lg: CfLogic, rests: CfBreak[], values: Record>, ): { links: Set; pills: Set; boxes: Set } { const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x)); const pills = new Set( lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`), ); const boxes = new Set(); const empty = (v: unknown): boolean => v === null || v === undefined || v === ""; for (const b of lg.boxes) { const incoming = lg.links.filter((l) => l.to.box === b.id); const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id)); // 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음) const outs = portsOut(b).map((p) => values[b.id]?.[p.name]); const hit = rests.some((k) => k.box === b.id); if (allRest || (hit && outs.every(empty))) boxes.add(b.id); } return { links, pills, boxes }; } /** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */ export function pillInfo( lg: CfLogic, p: DfPillSel, values: Record>, ): string { const name = (e: CfPort): string => `${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`; const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; const where = lg.links .filter((l) => mine(p.dir === "in" ? l.to : l.from)) .map((l) => name(p.dir === "in" ? l.from : l.to)) .join(", "); const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""] .filter(Boolean) .join(" · "); } /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ export function follow( ev: PointerEvent, move: (m: PointerEvent) => void, end: (u: PointerEvent, moved: boolean) => void, ): void { const x0 = ev.clientX; const y0 = ev.clientY; let moved = false; const mv = (m: PointerEvent): void => { if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; moved = true; move(m); }; const up = (u: PointerEvent): void => { window.removeEventListener("pointermove", mv); window.removeEventListener("pointerup", up); if (moved) { // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 const swallow = (c: MouseEvent): void => c.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); } end(u, moved); }; window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up); } /* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */ const SVG_NS = "http://www.w3.org/2000/svg"; /** SVG 마디 하나 붙이기 */ export function svgAdd( parent: Element, tag: K, attrs: Record, ): SVGElementTagNameMap[K] { const n = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); parent.append(n); return n; } /** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */ export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number { const tag = svgAdd(parent, "g", { class: cls }); const box = svgAdd(tag, "rect", { rx: "4", ry: "4" }); const t = svgAdd(tag, "text", { x: x.toFixed(1), y: y.toFixed(1), "dominant-baseline": "central", "text-anchor": "middle", }); t.textContent = text; const w = (t.getComputedTextLength?.() || text.length * 7) + 10; Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => box.setAttribute(k, v.toFixed(1)), ); return w; }