Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts
T
eomsangdonandClaude Opus 5.5 321e63f9c1 fix(M02): 흐름도 쉼 흐림 — 상자는 들어오는 선이 모두 쉼(또는 쉼이 걸리고 내보내는 값이 모두 빈 값)일 때만 · 일부면 그 선과 받는 알약만(PLAN 35-9 브레인 재검증)
- 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
2026-10-01 21:16:41 +09:00

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,
);
},
);
}
}