- restParts(Parts) · 받는 알약 쉼 모양(점선 · 흐린 글) - 시험 test_m02_df_diagram_parts.py 6 통과 · DataFlow 전부 검사 초록 · Diagram 699줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
700 lines
25 KiB
TypeScript
700 lines
25 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_Diagram.ts
|
|
* 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」)
|
|
* 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 ·
|
|
* 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) ·
|
|
* 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음.
|
|
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
|
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
|
|
import {
|
|
arrange,
|
|
clampZoom,
|
|
fitView,
|
|
KIND_COLOR,
|
|
depEnd,
|
|
depInTrace,
|
|
depPath,
|
|
follow,
|
|
depLines,
|
|
isRest,
|
|
restParts,
|
|
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";
|
|
|
|
export * from "./M02_DF_Diagram_Parts";
|
|
|
|
interface HostState {
|
|
selected: string | null;
|
|
linking: CfPort | null;
|
|
pill: DfPillSel | null;
|
|
note: string;
|
|
zoom: number;
|
|
panX: number;
|
|
panY: number;
|
|
fitted: boolean;
|
|
watch?: ResizeObserver;
|
|
}
|
|
|
|
const states = new WeakMap<HTMLElement, HostState>();
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
/** 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,
|
|
lg: CfLogic,
|
|
on: DiagramOn,
|
|
opts: DiagramOpts = {},
|
|
): void {
|
|
const st: HostState = states.get(host) ?? {
|
|
selected: null,
|
|
linking: null,
|
|
pill: null,
|
|
note: "",
|
|
zoom: 1,
|
|
panX: 0,
|
|
panY: 0,
|
|
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 allBreaks = opts.breaks ?? [];
|
|
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
|
|
const breaks = allBreaks.filter((k) => !isRest(k));
|
|
const rests = allBreaks.filter(isRest);
|
|
const {
|
|
links: restLinks,
|
|
pills: restPills,
|
|
boxes: restBoxes,
|
|
} = restParts(lg, rests, opts.values ?? {});
|
|
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
|
|
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 => {
|
|
const got = resolveDrop(lg, from, target);
|
|
st.linking = null;
|
|
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" });
|
|
const svg = document.createElementNS(SVG, "svg");
|
|
svg.classList.add("m02-dfdg__lines");
|
|
world.append(svg);
|
|
const view = el("div", {
|
|
className: "m02-dfdg__view",
|
|
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)}%`;
|
|
};
|
|
/** 화면 점 → 세상 자리 */
|
|
const toWorld = (cx: number, cy: number): { x: number; y: number } => {
|
|
const r = view.getBoundingClientRect();
|
|
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.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" : "",
|
|
broken ? "is-broken" : "",
|
|
dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "",
|
|
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,
|
|
"data-name": p.name,
|
|
"data-dir": dir,
|
|
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]) }),
|
|
],
|
|
});
|
|
btn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
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?.text ?? "");
|
|
});
|
|
if (dir === "out")
|
|
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
|
|
return btn;
|
|
};
|
|
|
|
/** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */
|
|
const plusSlot = (b: CfBox): HTMLElement => {
|
|
const slot = el("button", {
|
|
className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`,
|
|
text: "+",
|
|
attrs: {
|
|
type: "button",
|
|
"data-box": b.id,
|
|
"data-dir": "in",
|
|
"data-plus": "1",
|
|
title: tt("PlusIn"),
|
|
},
|
|
});
|
|
slot.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" });
|
|
st.note = tt("PlusIn");
|
|
redraw();
|
|
});
|
|
return slot;
|
|
};
|
|
|
|
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: 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" : ""}` +
|
|
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) +
|
|
(restBoxes.has(b.id) ? " is-rest" : ""),
|
|
attrs: {
|
|
"data-box": b.id,
|
|
tabindex: "0",
|
|
role: "button",
|
|
"aria-label": `${kind} · ${b.name}`,
|
|
},
|
|
children: [
|
|
head,
|
|
el("div", {
|
|
className: "m02-dfdg__pills",
|
|
children: [
|
|
el("div", {
|
|
className: "m02-dfdg__ins",
|
|
children: [
|
|
...ins.map((p) => pillEl(b, p, "in")),
|
|
...(b.kind === "function" ? [plusSlot(b)] : []),
|
|
],
|
|
}),
|
|
el("div", {
|
|
className: "m02-dfdg__outs",
|
|
children: outs.map((p) => pillEl(b, p, "out")),
|
|
}),
|
|
],
|
|
}),
|
|
...(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`;
|
|
node.style.top = `${b.y}px`;
|
|
node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]);
|
|
node.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null });
|
|
select(st.selected === b.id ? null : b.id);
|
|
});
|
|
node.addEventListener("keydown", (ev) => {
|
|
if (ev.target !== node) return;
|
|
if (ev.key === "Enter") {
|
|
ev.preventDefault();
|
|
node.click();
|
|
}
|
|
const step = ev.shiftKey ? 50 : 10;
|
|
const d = {
|
|
ArrowLeft: [-step, 0],
|
|
ArrowRight: [step, 0],
|
|
ArrowUp: [0, -step],
|
|
ArrowDown: [0, step],
|
|
}[ev.key];
|
|
if (d && st.selected === b.id) {
|
|
ev.preventDefault();
|
|
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
|
|
}
|
|
});
|
|
head.addEventListener("pointerdown", (ev) => {
|
|
if (ev.button === 0 && !spaceDown) dragBox(ev, b, node);
|
|
});
|
|
return node;
|
|
};
|
|
|
|
world.append(...lg.boxes.map(boxEl));
|
|
|
|
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
|
|
const setZoom = (z: number, cx?: number, cy?: number): void => {
|
|
const r = view.getBoundingClientRect();
|
|
const px = cx ?? r.left + r.width / 2;
|
|
const py = cy ?? r.top + r.height / 2;
|
|
const at = toWorld(px, py);
|
|
st.zoom = clampZoom(z);
|
|
st.panX = px - r.left - at.x * st.zoom;
|
|
st.panY = py - r.top - at.y * st.zoom;
|
|
applyView();
|
|
drawLines();
|
|
};
|
|
const fit = (): void => {
|
|
const rects = lg.boxes.map((b) => {
|
|
const n = world.querySelector<HTMLElement>(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`);
|
|
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
|
|
});
|
|
const r = view.getBoundingClientRect();
|
|
Object.assign(st, fitView(rects, { w: r.width, h: r.height }));
|
|
applyView();
|
|
drawLines();
|
|
};
|
|
const freeSpot = (): { x: number; y: number } => {
|
|
const r = view.getBoundingClientRect();
|
|
const c = toWorld(r.left + r.width / 3, r.top + r.height / 3);
|
|
let y = c.y;
|
|
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,
|
|
variant: "ghost" | "filled" | "danger" = "ghost",
|
|
) => createButton({ label: tt(label), size: "sm", variant, onClick });
|
|
const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement =>
|
|
sm(label, () => {
|
|
const at = freeSpot();
|
|
on.add(kind, at.x, at.y);
|
|
});
|
|
const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`;
|
|
|
|
const selLink = lg.links.find((l) => l.id === st.selected) ?? null;
|
|
const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null;
|
|
const askRemove = async (b: CfBox): Promise<void> => {
|
|
if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id);
|
|
};
|
|
const tools: HTMLElement[] = [];
|
|
if (st.linking) {
|
|
tools.push(
|
|
el("span", { text: tt("PickTarget", { v: st.linking.name }) }),
|
|
sm("Cancel", () => ((st.linking = null), redraw())),
|
|
);
|
|
} else if (st.pill) {
|
|
const p = st.pill;
|
|
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.port]) || "—"}`;
|
|
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
|
|
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.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: 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",
|
|
children: [
|
|
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
|
|
addBtn("input", "AddInput"),
|
|
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)),
|
|
sm("Fit", fit),
|
|
sm("Arrange", () => {
|
|
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
|
|
}),
|
|
],
|
|
});
|
|
const root = el("div", {
|
|
className: `m02-dfdg${st.linking ? " is-linking" : ""}`,
|
|
children: [
|
|
bar,
|
|
el("div", {
|
|
className: "m02-dfdg__tools",
|
|
children: [
|
|
...tools,
|
|
el("span", { className: "m02-dfdg__note", text: st.note }),
|
|
el("span", {
|
|
className: "m02-dfdg__hint",
|
|
text: tools.length || st.note ? "" : tt("Hint"),
|
|
}),
|
|
],
|
|
}),
|
|
view,
|
|
],
|
|
});
|
|
root.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Escape" && (st.linking || st.pill)) {
|
|
st.linking = null;
|
|
st.pill = null;
|
|
redraw();
|
|
}
|
|
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
|
|
if (selLink) on.unlink(selLink.id);
|
|
else if (selBox) void askRemove(selBox);
|
|
});
|
|
view.addEventListener("click", (ev) => {
|
|
if (ev.target === view || ev.target === world) select(null);
|
|
});
|
|
view.addEventListener(
|
|
"wheel",
|
|
(ev) => {
|
|
ev.preventDefault();
|
|
setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY);
|
|
},
|
|
{ passive: false },
|
|
);
|
|
// 판 옮기기 = 가운데 단추 끌기(어디서나) · 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());
|
|
if (!lg.boxes.length)
|
|
view.append(
|
|
el("ol", {
|
|
className: "m02-dfdg__empty",
|
|
children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })),
|
|
}),
|
|
);
|
|
host.replaceChildren(root);
|
|
applyView();
|
|
|
|
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */
|
|
function drawLines(): void {
|
|
svg.replaceChildren();
|
|
const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => {
|
|
const pill = world.querySelector<HTMLElement>(
|
|
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
|
|
);
|
|
if (!pill) return null;
|
|
const r = pill.getBoundingClientRect();
|
|
const right = (dir === "out") === (side === "near");
|
|
return toWorld(right ? 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;
|
|
};
|
|
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", {
|
|
x: x.toFixed(1),
|
|
y: y.toFixed(1),
|
|
"dominant-baseline": "central",
|
|
"text-anchor": "middle",
|
|
});
|
|
t.textContent = text;
|
|
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
|
|
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", {
|
|
id: "m02-dfdg-arrow",
|
|
viewBox: "0 0 8 8",
|
|
refX: "7",
|
|
refY: "4",
|
|
markerWidth: "7",
|
|
markerHeight: "7",
|
|
orient: "auto",
|
|
});
|
|
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
|
|
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");
|
|
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 strong =
|
|
st.selected === l.id ||
|
|
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.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 rest = restLinks.has(l.id) ? " is-rest" : "";
|
|
const g = add(svg, "g", {
|
|
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`,
|
|
"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);
|
|
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)]
|
|
.map((k) => k.text)
|
|
.join(" · ");
|
|
if (broken.length) {
|
|
const cy = (mid.y - (rule ? 18 : 0)).toFixed(1);
|
|
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
|
|
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
|
|
const t = add(badge, "text", {
|
|
x: mid.x.toFixed(1),
|
|
y: cy,
|
|
"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, undefined, st.selected === l.id ? "" : why);
|
|
});
|
|
}
|
|
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
|
|
for (const dep of depLines(lg)) {
|
|
// 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽
|
|
const a = at(dep.from, "out");
|
|
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 =
|
|
steps?.back && depInTrace(dep, steps.back)
|
|
? " is-back"
|
|
: steps?.forward && depInTrace(dep, steps.forward)
|
|
? " is-fwd"
|
|
: steps && (steps.back?.length || steps.forward?.length)
|
|
? " is-dim"
|
|
: "";
|
|
const g = add(svg, "g", {
|
|
class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`,
|
|
"data-dep": dep.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__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) => {
|
|
ev.stopPropagation();
|
|
select(where);
|
|
});
|
|
}
|
|
}
|
|
const firstFit = (): void => {
|
|
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
|
|
st.fitted = true;
|
|
fit();
|
|
};
|
|
st.watch?.disconnect();
|
|
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit);
|
|
st.watch?.observe(view);
|
|
if (view.isConnected) firstFit();
|
|
|
|
/* 끌기 셋 — 상자 · 판 · 선 */
|
|
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;
|
|
follow(
|
|
ev,
|
|
(m) => {
|
|
const now = toWorld(m.clientX, m.clientY);
|
|
x = Math.round(b.x + now.x - start.x);
|
|
y = Math.round(b.y + now.y - start.y);
|
|
node.style.left = `${x}px`;
|
|
node.style.top = `${y}px`;
|
|
drawLines();
|
|
},
|
|
(_u, moved) => {
|
|
if (moved) on.move(b.id, x, y);
|
|
},
|
|
);
|
|
}
|
|
|
|
function dragPan(ev: PointerEvent): void {
|
|
const p0 = { x: st.panX, y: st.panY };
|
|
view.classList.add("is-panning");
|
|
follow(
|
|
ev,
|
|
(m) => {
|
|
st.panX = p0.x + m.clientX - ev.clientX;
|
|
st.panY = p0.y + m.clientY - ev.clientY;
|
|
applyView();
|
|
},
|
|
() => 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);
|
|
let line: SVGPathElement | null = null;
|
|
follow(
|
|
ev,
|
|
(m) => {
|
|
const b = toWorld(m.clientX, m.clientY);
|
|
if (!line) {
|
|
line = document.createElementNS(SVG, "path");
|
|
line.setAttribute("class", "m02-dfdg__drag");
|
|
svg.append(line);
|
|
}
|
|
line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`);
|
|
},
|
|
(u, moved) => {
|
|
if (!moved) return;
|
|
line?.remove();
|
|
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
|
|
const p = hit?.closest<HTMLElement>(".m02-dfdg__pill");
|
|
const bx = hit?.closest<HTMLElement>(".m02-dfdg__box");
|
|
tryLink(
|
|
from,
|
|
p
|
|
? {
|
|
box: p.dataset.box ?? "",
|
|
name: p.dataset.name ?? null,
|
|
dir: (p.dataset.dir as "in" | "out") ?? null,
|
|
}
|
|
: bx
|
|
? { box: bx.dataset.box ?? "", name: null, dir: null }
|
|
: null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|