feat(M02): 흐름도 다이어그램 — 넓힌 타입에 맞춤 · 선 규칙 이름표(CfLink.rule) · 흐름 길(CfStep) · 끊긴 곳(CfBreak) · 고른 선 · 상자는 흐리게 안 함(PLAN 35-9)

- 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
This commit is contained in:
2026-10-01 19:49:24 +09:00
co-authored by Claude Opus 5.5
parent a0d0b0354e
commit b67dcc804c
2 changed files with 46 additions and 35 deletions
+14 -12
View File
@@ -14,6 +14,7 @@ import {
clampZoom,
fitView,
KIND_COLOR,
linkLabel,
midOf,
pillsOf,
resolveDrop,
@@ -135,7 +136,7 @@ export function renderDiagram(
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.port === p.name && st.pill.dir === dir;
const broken = breaks.find((k) => k.port?.box === b.id && k.port.name === p.name);
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:
@@ -146,7 +147,7 @@ export function renderDiagram(
chosen ? "is-chosen" : "",
broken ? "is-broken" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" ") + markOf((s) => !!s.pills?.some((x) => x.box === b.id && x.name === p.name)),
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
attrs: {
type: "button",
"data-box": b.id,
@@ -165,7 +166,7 @@ export function renderDiagram(
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
st.linking = null;
if (chosen) select(null);
else select(b.id, sel, broken?.why ?? "");
else select(b.id, sel, broken?.text ?? "");
});
if (dir === "out")
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
@@ -197,7 +198,7 @@ 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.box === b.id);
const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id);
const head = el("div", {
className: "m02-dfdg__head",
children: [
@@ -209,7 +210,7 @@ export function renderDiagram(
const node = el("div", {
className:
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
markOf((s) => !!s.boxes?.includes(b.id)),
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))),
attrs: {
"data-box": b.id,
tabindex: "0",
@@ -236,8 +237,8 @@ export function renderDiagram(
}),
...(broken.length
? [
warn(broken.map((k) => k.why).join(" · "), () =>
select(b.id, undefined, broken.map((k) => k.why).join(" · ")),
warn(broken.map((k) => k.text).join(" · "), () =>
select(b.id, undefined, broken.map((k) => k.text).join(" · ")),
),
]
: []),
@@ -349,7 +350,7 @@ export function renderDiagram(
),
);
} else if (selLink) {
const rule = labels[selLink.id];
const rule = labels[selLink.id] ?? linkLabel(selLink);
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: rule ? `${line} · ${rule}` : line }),
@@ -470,7 +471,8 @@ export function renderDiagram(
const strong =
st.selected === l.id ||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
const mark = markOf((s) => !!s.links?.includes(l.id));
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}`,
@@ -480,7 +482,7 @@ export function renderDiagram(
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];
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" });
@@ -498,7 +500,7 @@ export function renderDiagram(
}
const broken = breaks.filter((k) => k.link === l.id);
if (broken.length) {
const why = broken.map((k) => k.why).join(" · ");
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),
@@ -516,7 +518,7 @@ export function renderDiagram(
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
const why = broken.map((k) => k.why).join(" · ");
const why = broken.map((k) => k.text).join(" · ");
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
});
}
@@ -13,7 +13,11 @@ import {
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"],
@@ -81,21 +85,6 @@ export interface DfPillSel {
dir: "in" | "out";
}
/** 흐름 길 한쪽 — 칠할 상자 · 선 · 알약 */
export interface DfTraceSet {
boxes?: string[];
links?: string[];
pills?: { box: string; name: string }[];
}
/** 끊긴 곳 — 상자 · 선 · 알약 중 하나 + 까닭 */
export interface DfBreak {
box?: string;
link?: string;
port?: { box: string; name: string };
why: string;
}
export interface DiagramOn {
/** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */
select(id: string | null, pill?: DfPillSel): void;
@@ -114,12 +103,12 @@ export interface DiagramOpts {
selected?: string | null;
/** 고른 알약 */
selectedPill?: DfPillSel | null;
/** 선 id → 선 규칙 글(「표: 석공 칸」 · 「× 1.3」) — 없으면 이름표 없음 */
/** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */
labels?: Record<string, string>;
/** 흐름 길 — 온 길 · 가는 길 */
trace?: { back?: DfTraceSet; forward?: DfTraceSet } | null;
/** 끊긴 곳 */
breaks?: DfBreak[];
/** 흐름 길 — Model traceBack · traceForward 걸음 */
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -216,18 +205,38 @@ export const showValue = (v: unknown): string => {
return String(v).slice(0, 12);
};
/* ── 흐름 길 ────────────────────────────────────────────────────────── */
/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */
/** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */
export type TraceMark = "back" | "fwd" | "dim" | "";
export function traceOf(trace: DiagramOpts["trace"], pick: (s: DfTraceSet) => boolean): TraceMark {
if (!trace || (!trace.back && !trace.forward)) return "";
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 },