- 시작에 작은 점 · 끝에 화살 · 끝 계산 depEnd · depPath(Parts) - 시험 test_m02_df_diagram_parts.py 5 통과 · DataFlow 전부 검사 초록 · Diagram 697줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
339 lines
14 KiB
TypeScript
339 lines
14 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,
|
|
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, 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,
|
|
});
|
|
|
|
/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */
|
|
|
|
/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */
|
|
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 === "쉼";
|
|
|
|
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
|
|
|
|
/** 누른 뒤 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);
|
|
}
|