fix(M02): 흐름도 기댐 선 끝을 알약 · 이름표에 붙임 — 시작 = 조건 알약 오른쪽 · 끝 = 규칙 선 이름표 가장자리 또는 재료 알약 오른쪽(PLAN 35-9 브레인 재검증)

- 시작에 작은 점 · 끝에 화살 · 끝 계산 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
This commit is contained in:
2026-10-01 21:02:21 +09:00
co-authored by Claude Opus 5.5
parent 3eb18bf74b
commit 9052cb7411
3 changed files with 68 additions and 12 deletions
+28 -12
View File
@@ -14,7 +14,9 @@ import {
clampZoom,
fitView,
KIND_COLOR,
depEnd,
depInTrace,
depPath,
follow,
depLines,
isRest,
@@ -483,7 +485,7 @@ export function renderDiagram(
parent.append(n);
return n;
};
const label = (parent: Element, x: number, y: number, text: string, cls: string): void => {
const label = (parent: Element, x: number, y: number, text: string, cls: string): number => {
const tag = add(parent, "g", { class: cls });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const t = add(tag, "text", {
@@ -497,6 +499,7 @@ export function renderDiagram(
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
return w;
};
const defs = add(svg, "defs", {});
const arrow = add(defs, "marker", {
@@ -509,7 +512,16 @@ export function renderDiagram(
orient: "auto",
});
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
const mids = new Map<string, { x: number; y: number }>();
const dot = add(defs, "marker", {
id: "m02-dfdg-dot",
viewBox: "0 0 8 8",
refX: "4",
refY: "4",
markerWidth: "6",
markerHeight: "6",
});
add(dot, "circle", { cx: "4", cy: "4", r: "3", class: "m02-dfdg__arrow" });
const mids = new Map<string, { x: number; y: number; w: number }>();
for (const l of lg.links) {
const a = at(l.from, "out");
const b = at(l.to, "in");
@@ -531,9 +543,8 @@ export function renderDiagram(
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", { d, class: "m02-dfdg__wire" });
const mid = midOf(a, b);
mids.set(l.id, mid);
const rule = labels[l.id] ?? linkLabel(l);
if (rule) label(g, mid.x, mid.y, rule, "m02-dfdg__rule");
mids.set(l.id, { ...mid, w: rule ? label(g, mid.x, mid.y, rule, "m02-dfdg__rule") : 0 });
if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark");
const broken = breaks.filter((k) => k.link === l.id);
const why = [...broken, ...rests.filter((k) => k.link === l.id)]
@@ -559,14 +570,14 @@ export function renderDiagram(
}
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
for (const dep of depLines(lg)) {
// 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽
const a = at(dep.from, "out");
const b =
"link" in dep.to
? mids.get(dep.to.link)
: at({ box: dep.to.box, name: dep.to.port }, "out", "far");
if (!a || !b) continue;
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
const tag = "link" in dep.to ? mids.get(dep.to.link) : null;
const pill = "box" in dep.to ? at({ box: dep.to.box, name: dep.to.port }, "out") : null;
const end = tag ? depEnd(a, tag) : pill && { ...pill, side: "right" as const };
if (!a || !end) continue;
const b = { x: end.x, y: end.y };
const d = depPath(a, b, end.side);
const where = "link" in dep.to ? dep.to.link : dep.to.box;
const steps = opts.trace;
const traced =
@@ -583,7 +594,12 @@ export function renderDiagram(
"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__depwire", "marker-end": "url(#m02-dfdg-arrow)" });
add(g, "path", {
d,
class: "m02-dfdg__depwire",
"marker-start": "url(#m02-dfdg-dot)",
"marker-end": "url(#m02-dfdg-arrow)",
});
const m = midOf(a, b);
label(g, m.x, m.y, dep.text, "m02-dfdg__deptext");
g.addEventListener("click", (ev) => {
@@ -284,6 +284,25 @@ export function depInTrace(dep: DepLine, steps: CfStep[]): boolean {
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 === "쉼";
@@ -45,6 +45,8 @@ const out = jobs.map((job) => {
if (job.op === "rest") return job.breaks.map((b) => m.isRest(b));
if (job.op === "fit") return m.fitView(job.rects, job.view);
if (job.op === "value") return job.values.map((v) => m.showValue(v));
if (job.op === "end") return m.depEnd(job.a, job.tag);
if (job.op === "path") return m.depPath(job.a, job.b, job.side);
return null;
});
process.stdout.write(JSON.stringify(out));
@@ -248,3 +250,22 @@ def test_한눈에_90_아래로_안_내림과_알약_값():
)
assert fit["zoom"] == 0.9
assert values == ["97,882.512", "268,908", "0.364", "깬돌", "· 2줄"]
def test_기댐_선_끝이_알약과_이름표에_붙음():
left, right, path_left, path_right = run(
[
{"op": "end", "a": {"x": 100, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
{"op": "end", "a": {"x": 600, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 358, "y": 120}, "side": "left"},
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 130, "y": 300}, "side": "right"},
]
)
# 시작이 왼쪽이면 이름표 왼쪽 가장자리(400 − 40 − 2) · 오른쪽이면 오른쪽 가장자리
assert left == {"x": 358, "y": 120, "side": "left"}
assert right == {"x": 442, "y": 120, "side": "right"}
# 길은 시작 알약에서 나가 끝에서 끝남 · 재료 알약은 오른쪽에서 들어감(끝 손잡이가 끝보다 오른쪽)
assert path_left.startswith("M100,50 ") and path_left.endswith(" 358,120")
assert path_right.startswith("M100,50 ") and path_right.endswith(" 130,300")
handle = float(path_right.split(" ")[2].split(",")[0])
assert handle > 130