Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-01 19:51:27 +09:00
3 changed files with 575 additions and 234 deletions
+137 -1
View File
@@ -70,8 +70,15 @@
transparent 1px
);
background-size: 20px 20px;
cursor: grab;
cursor: default;
touch-action: none;
/* 왼쪽 끌기로 글자가 잡히지 않게 */
user-select: none;
-webkit-user-select: none;
}
.m02-dfdg__view.is-panning {
cursor: grabbing;
}
.m02-dfdg__world {
@@ -274,3 +281,132 @@
border-color: var(--cfdg-in);
color: var(--cfdg-in);
}
/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */
.m02-dfdg {
--dfdg-back: var(--color-accent);
--dfdg-fwd: #c2410c;
}
:root[data-theme="dark"] .m02-dfdg {
--dfdg-fwd: #f0a35e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .m02-dfdg {
--dfdg-fwd: #f0a35e;
}
}
.m02-dfdg__rule rect {
fill: var(--color-surface);
stroke: var(--color-border);
}
.m02-dfdg__rule text {
fill: var(--color-text-secondary);
font-size: 11px;
}
.m02-dfdg__link.is-strong .m02-dfdg__rule rect {
stroke: var(--color-accent);
}
/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */
.m02-dfdg__link.is-back .m02-dfdg__wire {
stroke: var(--dfdg-back);
stroke-width: 3.5;
}
.m02-dfdg__link.is-fwd .m02-dfdg__wire {
stroke: var(--dfdg-fwd);
stroke-width: 3.5;
stroke-dasharray: 8 5;
}
.m02-dfdg__link.is-dim .m02-dfdg__wire,
.m02-dfdg__link.is-dim .m02-dfdg__rule {
opacity: 0.25;
}
.m02-dfdg__box.is-back {
outline: 3px solid var(--dfdg-back);
outline-offset: 2px;
}
.m02-dfdg__box.is-fwd {
outline: 3px dashed var(--dfdg-fwd);
outline-offset: 2px;
}
.m02-dfdg__box.is-dim {
opacity: 0.45;
}
.m02-dfdg__pill.is-back {
box-shadow: 0 0 0 2px var(--dfdg-back);
}
.m02-dfdg__pill.is-fwd {
outline: 2px dashed var(--dfdg-fwd);
outline-offset: 1px;
}
.m02-dfdg__legend {
padding: 0 var(--spacing-8);
border-radius: 999px;
font-size: var(--text-caption);
}
.m02-dfdg__legend.is-back {
border: 2px solid var(--dfdg-back);
color: var(--dfdg-back);
}
.m02-dfdg__legend.is-fwd {
border: 2px dashed var(--dfdg-fwd);
color: var(--dfdg-fwd);
}
/* 고른 알약 테두리 */
.m02-dfdg__pill.is-chosen {
box-shadow: 0 0 0 2px var(--color-text);
}
/* 끊긴 곳 */
.m02-dfdg__warn {
position: absolute;
top: -9px;
right: -9px;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--color-danger);
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 18px;
cursor: pointer;
}
.m02-dfdg__pill.is-broken {
border-color: var(--color-danger);
}
.m02-dfdg__pill-warn {
color: var(--color-danger);
font-weight: 700;
}
.m02-dfdg__linkwarn circle {
fill: var(--color-danger);
}
.m02-dfdg__linkwarn text {
fill: #fff;
font-size: 11px;
font-weight: 700;
}
+191 -233
View File
@@ -1,184 +1,39 @@
/* =============================================================================
* M02_DF_Diagram.ts
* 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」)
* 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 ·
* 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) ·
* 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게.
* SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로).
* 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」)
* 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 ·
* 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) ·
* 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음.
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
} from "./M02_DF_Model";
arrange,
clampZoom,
fitView,
KIND_COLOR,
linkLabel,
midOf,
pillsOf,
resolveDrop,
showValue,
traceOf,
tt,
type DfPillSel,
type DiagramOn,
type DiagramOpts,
type TEXT,
} from "./M02_DF_Diagram_Parts";
import "./M02_DF_Diagram.css";
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: [
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
"Drag head to move · drag space to pan",
],
} as const;
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 DiagramOn {
select(id: string | null): 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;
}
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
/** 알약 — 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;
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · 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) };
}
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/* ── 그리기 ─────────────────────────────────────────────────────── */
export * from "./M02_DF_Diagram_Parts";
interface HostState {
selected: string | null;
linking: CfPort | null;
pill: { box: string; name: string; dir: "in" | "out" } | null;
pill: DfPillSel | null;
note: string;
zoom: number;
panX: number;
@@ -189,18 +44,16 @@ interface HostState {
const states = new WeakMap<HTMLElement, HostState>();
const SVG = "http://www.w3.org/2000/svg";
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 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);
};
/** Space 를 누르고 있음 — 터치패드용 판 옮기기 */
let spaceDown = false;
if (typeof window !== "undefined") {
window.addEventListener("keydown", (e) => {
if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select"))
spaceDown = true;
});
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
window.addEventListener("blur", () => (spaceDown = false));
}
export function renderDiagram(
host: HTMLElement,
@@ -219,15 +72,19 @@ export function renderDiagram(
fitted: false,
};
if (opts.selected !== undefined) st.selected = opts.selected;
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
states.set(host, st);
const values = opts.values ?? {};
const labels = opts.labels ?? {};
const breaks = opts.breaks ?? [];
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected });
const select = (id: string | null): void => {
st.selected = id;
st.pill = null;
st.note = "";
on.select(id);
const redraw = (): void =>
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
const select = (id: string | null, pill?: DfPillSel, note = ""): void => {
st.selected = pill ? null : id;
st.pill = pill ?? null;
st.note = note;
on.select(id, pill);
redraw();
};
const tryLink = (from: CfPort, target: Parameters<typeof resolveDrop>[2]): void => {
@@ -236,6 +93,23 @@ export function renderDiagram(
st.note = "why" in got ? got.why : on.link(from, got.to) || "";
redraw();
};
const markOf = (pick: Parameters<typeof traceOf>[1]): string => {
const m = traceOf(opts.trace, pick);
return m ? ` is-${m}` : "";
};
const warn = (why: string, onClick: () => void): HTMLElement => {
const b = el("button", {
className: "m02-dfdg__warn",
text: "!",
attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` },
});
b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
b.addEventListener("click", (ev) => {
ev.stopPropagation();
onClick();
});
return b;
};
/* 판 · 세상(확대 · 옮김을 한 번에) */
const world = el("div", { className: "m02-dfdg__world" });
@@ -247,6 +121,7 @@ export function renderDiagram(
attrs: { "aria-label": tt("Title") },
children: [world],
});
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
const applyView = (): void => {
world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`;
zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`;
@@ -257,18 +132,22 @@ export function renderDiagram(
return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom };
};
/* 상자 */
/* 상자 · 알약 */
const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => {
const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name);
const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir;
const chosen = st.pill?.box === b.id && st.pill.port === p.name && st.pill.dir === dir;
const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name);
const sel: DfPillSel = { box: b.id, port: p.name, dir };
const btn = el("button", {
className: [
"m02-dfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" "),
className:
[
"m02-dfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
broken ? "is-broken" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
attrs: {
type: "button",
"data-box": b.id,
@@ -277,6 +156,7 @@ export function renderDiagram(
title: `${p.name} (${p.unit || "-"})`,
},
children: [
...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []),
el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }),
],
@@ -284,10 +164,9 @@ export function renderDiagram(
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
st.pill = chosen ? null : { box: b.id, name: p.name, dir };
st.linking = null;
st.note = "";
redraw();
if (chosen) select(null);
else select(b.id, sel, broken?.text ?? "");
});
if (dir === "out")
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
@@ -318,21 +197,25 @@ export function renderDiagram(
const boxEl = (b: CfBox): HTMLElement => {
const { ins, outs } = pillsOf(b);
const kind = tt(`K_${b.kind}` as keyof typeof TEXT);
const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id);
const head = el("div", {
className: "m02-dfdg__head",
children: [
el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }),
el("span", { className: "m02-dfdg__kind", text: kind }),
el("strong", { className: "m02-dfdg__name", text: b.name }),
el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }),
],
});
const node = el("div", {
className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`,
className:
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))),
attrs: {
"data-box": b.id,
tabindex: "0",
role: "button",
"aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`,
"aria-label": `${kind} · ${b.name}`,
},
children: [
head,
@@ -352,6 +235,13 @@ export function renderDiagram(
}),
],
}),
...(broken.length
? [
warn(broken.map((k) => k.text).join(" · "), () =>
select(b.id, undefined, broken.map((k) => k.text).join(" · ")),
),
]
: []),
],
});
node.style.left = `${b.x}px`;
@@ -364,7 +254,7 @@ export function renderDiagram(
});
node.addEventListener("keydown", (ev) => {
if (ev.target !== node) return;
if (ev.key === "Enter" || ev.key === " ") {
if (ev.key === "Enter") {
ev.preventDefault();
node.click();
}
@@ -380,14 +270,15 @@ export function renderDiagram(
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node));
head.addEventListener("pointerdown", (ev) => {
if (ev.button === 0 && !spaceDown) dragBox(ev, b, node);
});
return node;
};
world.append(...lg.boxes.map(boxEl));
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
const setZoom = (z: number, cx?: number, cy?: number): void => {
const r = view.getBoundingClientRect();
const px = cx ?? r.left + r.width / 2;
@@ -416,7 +307,7 @@ export function renderDiagram(
while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100;
return { x: Math.round(c.x), y: Math.round(y) };
};
/** 작은 단추 */
/** 공용 작은 단추 */
const sm = (
label: keyof typeof TEXT,
onClick: () => void,
@@ -442,31 +333,39 @@ export function renderDiagram(
);
} else if (st.pill) {
const p = st.pill;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.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) => endName(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`;
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") }));
tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") }));
if (p.dir === "out")
tools.push(
sm(
"Connect",
() => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()),
() => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()),
"filled",
),
);
} else if (selLink) {
const rule = labels[selLink.id] ?? linkLabel(selLink);
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: line }),
el("span", { text: rule ? `${line} · ${rule}` : line }),
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
);
} else if (selBox) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const legend =
opts.trace && (opts.trace.back || opts.trace.forward)
? [
el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }),
el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }),
]
: [];
const bar = el("div", {
className: "m02-dfdg__bar",
@@ -476,6 +375,7 @@ export function renderDiagram(
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
el("span", { className: "m02-dfdg__gap" }),
...legend,
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
@@ -494,7 +394,10 @@ export function renderDiagram(
children: [
...tools,
el("span", { className: "m02-dfdg__note", text: st.note }),
el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }),
el("span", {
className: "m02-dfdg__hint",
text: tools.length || st.note ? "" : tt("Hint"),
}),
],
}),
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
@@ -521,14 +424,24 @@ export function renderDiagram(
},
{ passive: false },
);
view.addEventListener("pointerdown", (ev) => {
if (ev.target !== view && ev.target !== world && ev.target !== svg) return;
dragPan(ev);
});
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함
view.addEventListener(
"pointerdown",
(ev) => {
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
ev.preventDefault();
ev.stopPropagation();
dragPan(ev);
}
},
{ capture: true },
);
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 */
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
@@ -539,6 +452,16 @@ export function renderDiagram(
const r = pill.getBoundingClientRect();
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
};
const add = <K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
};
for (const l of lg.links) {
const a = at(l.from, "out");
const b = at(l.to, "in");
@@ -548,24 +471,56 @@ export function renderDiagram(
const strong =
st.selected === l.id ||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
const g = document.createElementNS(SVG, "g");
g.setAttribute(
"class",
`m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`,
);
g.setAttribute("data-link", l.id);
g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","));
for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) {
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", d);
p.setAttribute("class", cls);
g.append(p);
const traced = markOf((s) => s.some((x) => x.link === l.id));
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
const g = add(svg, "g", {
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`,
"data-link": l.id,
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
});
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", { d, class: "m02-dfdg__wire" });
const mid = midOf(a, b);
const rule = labels[l.id] ?? linkLabel(l);
if (rule) {
const tag = add(g, "g", { class: "m02-dfdg__rule" });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const text = add(tag, "text", {
x: mid.x.toFixed(1),
y: mid.y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
text.textContent = rule;
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
}
const broken = breaks.filter((k) => k.link === l.id);
if (broken.length) {
const why = broken.map((k) => k.text).join(" · ");
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
add(badge, "circle", {
cx: mid.x.toFixed(1),
cy: (mid.y - (rule ? 18 : 0)).toFixed(1),
r: "8",
});
const t = add(badge, "text", {
x: mid.x.toFixed(1),
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = "!";
add(badge, "title", {}).textContent = why;
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === l.id ? null : l.id);
const why = broken.map((k) => k.text).join(" · ");
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
});
svg.append(g);
}
}
const firstFit = (): void => {
@@ -584,7 +539,6 @@ export function renderDiagram(
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
if (ev.button !== 0) return;
const x0 = ev.clientX;
const y0 = ev.clientY;
let moved = false;
@@ -609,6 +563,7 @@ export function renderDiagram(
}
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
const start = toWorld(ev.clientX, ev.clientY);
let x = b.x;
let y = b.y;
@@ -630,6 +585,7 @@ export function renderDiagram(
function dragPan(ev: PointerEvent): void {
const p0 = { x: st.panX, y: st.panY };
view.classList.add("is-panning");
follow(
ev,
(m) => {
@@ -637,12 +593,14 @@ export function renderDiagram(
st.panY = p0.y + m.clientY - ev.clientY;
applyView();
},
() => undefined,
() => view.classList.remove("is-panning"),
);
}
function dragLink(ev: PointerEvent, from: CfPort): void {
if (ev.button !== 0 || spaceDown) return;
ev.stopPropagation();
ev.preventDefault();
const pill = ev.currentTarget as HTMLElement;
const r = pill.getBoundingClientRect();
const a = toWorld(r.right, r.top + r.height / 2);
@@ -0,0 +1,247 @@
/* =============================================================================
* 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,
});