Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts
T
eomsangdonandClaude Opus 5.5 745d164177 refactor(M02): 흐름도 다이어그램 — 선 규칙 글을 Rule.ruleLabel 그대로 · 가정한 대신 글 걷음(PLAN 35-9)
- Model · Eval 몸통(03736cba1) 받은 뒤 DataFlow 전부 검사 초록
- ORCA: 「표에서 찾기 (뒷길이 = 뒷길이) → 석공」 · 「× 1.3」 · 「지반 = 암 일 때만」 · 「소수 2자리 반올림」 · 규칙 없는 선 이름표 없음

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

234 lines
9.8 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 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,
});