- 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
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
});
|