Files
Aislo/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts
T

735 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_CF_Diagram.ts
* 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」)
* 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 ·
* 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) ·
* 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게.
* SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
} from "./M02_CF_Model";
import "./M02_CF_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 receiving pill with that name — drop on the pill itself",
],
Taken: [
"이미 이어진 알약 — 먼저 그 선을 끊으세요",
"That pill already has a line — disconnect it first",
],
From: ["어디서", "From"],
To: ["어디로", "To"],
Value: ["지금 값", "Now"],
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
Hint: [
"상자 머리를 끌어 옮김 · 빈 곳을 끌어 판 옮김 · 휠로 확대",
"Drag a box head to move · drag empty space to pan · wheel to zoom",
],
} 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[] } {
try {
return { ins: portsIn(box), outs: portsOut(box) };
} catch {
if (box.kind === "input") {
const inp = box.input;
const outs = [
...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })),
...(inp?.table ? [{ name: inp.table.key, unit: "-" }] : []),
...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })),
];
return { ins: [], outs };
}
if (box.kind === "function") {
const f = box.func;
return {
ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })),
outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [],
};
}
return { ins: [{ name: box.name, unit: box.unit }], outs: [] };
}
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
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);
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 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.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5));
return {
zoom,
panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom,
panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom,
};
}
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/* ── 그리기 ─────────────────────────────────────────────────────── */
interface HostState {
selected: string | null;
linking: CfPort | null;
pill: { box: string; name: string; dir: "in" | "out" } | 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";
const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
return String(v).slice(0, 12);
};
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;
states.set(host, st);
const values = 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 });
const select = (id: string | null): void => {
st.selected = id;
st.pill = null;
st.note = "";
on.select(id);
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 world = el("div", { className: "m02-cfdg__world" });
const svg = document.createElementNS(SVG, "svg");
svg.classList.add("m02-cfdg__lines");
world.append(svg);
const view = el("div", {
className: "m02-cfdg__view",
attrs: { "aria-label": tt("Title") },
children: [world],
});
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.name === p.name && st.pill.dir === dir;
const btn = el("button", {
className: [
"m02-cfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" "),
attrs: {
type: "button",
"data-box": b.id,
"data-name": p.name,
"data-dir": dir,
title: `${p.name} (${p.unit || "-"})`,
},
children: [
el("span", { className: "m02-cfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
el("span", { className: "m02-cfdg__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.pill = chosen ? null : { box: b.id, name: p.name, dir };
st.linking = null;
st.note = "";
redraw();
});
if (dir === "out")
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
return btn;
};
const boxEl = (b: CfBox): HTMLElement => {
const { ins, outs } = pillsOf(b);
const head = el("div", {
className: "m02-cfdg__head",
children: [
el("span", { className: "m02-cfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }),
el("strong", { className: "m02-cfdg__name", text: b.name }),
el("span", { className: "m02-cfdg__unit", text: b.unit || "-" }),
],
});
const node = el("div", {
className: `m02-cfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`,
attrs: {
"data-box": b.id,
tabindex: "0",
role: "button",
"aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`,
},
children: [
head,
el("div", {
className: "m02-cfdg__pills",
children: [
el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }),
el("div", {
className: "m02-cfdg__outs",
children: outs.map((p) => pillEl(b, p, "out")),
}),
],
}),
],
});
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.key === " ") {
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) => dragBox(ev, b, node));
return node;
};
world.append(...lg.boxes.map(boxEl));
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
const zoomLabel = el("span", { className: "m02-cfdg__zoom" });
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-cfdg__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 addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement =>
createButton({
label: tt(label),
size: "sm",
variant: "ghost",
onClick: () => {
const at = freeSpot();
on.add(kind, at.x, at.y);
},
});
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 }) }),
createButton({
label: tt("Cancel"),
size: "sm",
variant: "ghost",
onClick: () => ((st.linking = null), redraw()),
}),
);
} else if (st.pill) {
const p = st.pill;
const linked = lg.links.filter((l) =>
p.dir === "in"
? l.to.box === p.box && l.to.name === p.name
: l.from.box === p.box && l.from.name === p.name,
);
const where = linked
.map((l) =>
p.dir === "in"
? `${boxName(l.from.box)} · ${l.from.name}`
: `${boxName(l.to.box)} · ${l.to.name}`,
)
.join(", ");
tools.push(
el("span", {
text: [
p.name,
`${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`,
where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "",
]
.filter(Boolean)
.join(" · "),
}),
);
if (p.dir === "out")
tools.push(
createButton({
label: tt("Connect"),
size: "sm",
onClick: () => {
st.linking = { box: p.box, name: p.name };
st.pill = null;
redraw();
},
}),
);
} else if (selLink) {
tools.push(
el("span", {
text: tt("LineOf", {
a: `${boxName(selLink.from.box)} · ${selLink.from.name}`,
b: `${boxName(selLink.to.box)} · ${selLink.to.name}`,
}),
}),
createButton({
label: tt("Disconnect"),
size: "sm",
variant: "danger",
onClick: () => on.unlink(selLink.id),
}),
);
} else if (selBox) {
tools.push(
createButton({
label: tt("Remove"),
size: "sm",
variant: "danger",
onClick: () => void askRemove(selBox),
}),
);
}
const bar = el("div", {
className: "m02-cfdg__bar",
children: [
el("strong", { className: "m02-cfdg__title", text: tt("Title") }),
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
el("span", { className: "m02-cfdg__gap" }),
createButton({
label: "−",
size: "sm",
variant: "ghost",
onClick: () => setZoom(st.zoom / 1.2),
}),
zoomLabel,
createButton({
label: "+",
size: "sm",
variant: "ghost",
onClick: () => setZoom(st.zoom * 1.2),
}),
createButton({ label: tt("Fit"), size: "sm", variant: "ghost", onClick: fit }),
createButton({
label: tt("Arrange"),
size: "sm",
variant: "ghost",
onClick: () => {
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
},
}),
],
});
bar.querySelectorAll<HTMLButtonElement>("button").forEach((b) => {
if (b.textContent === "−") b.setAttribute("aria-label", tt("ZoomOut"));
if (b.textContent === "+") b.setAttribute("aria-label", tt("ZoomIn"));
});
const root = el("div", {
className: `m02-cfdg${st.linking ? " is-linking" : ""}`,
children: [
bar,
el("div", {
className: "m02-cfdg__tools",
children: [
...tools,
el("span", { className: "m02-cfdg__note", text: st.note }),
el("span", { className: "m02-cfdg__hint", text: tools.length ? "" : tt("Hint") }),
],
}),
lg.boxes.length ? view : el("p", { className: "m02-cfdg__hint", text: tt("Empty") }),
],
});
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 },
);
view.addEventListener("pointerdown", (ev) => {
if (ev.target !== view && ev.target !== world && ev.target !== svg) return;
dragPan(ev);
});
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
const pill = world.querySelector<HTMLElement>(
`.m02-cfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
);
if (!pill) return null;
const r = pill.getBoundingClientRect();
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
};
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 g = document.createElementNS(SVG, "g");
g.setAttribute(
"class",
`m02-cfdg__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-cfdg__hit", "m02-cfdg__wire"]) {
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", d);
p.setAttribute("class", cls);
g.append(p);
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === l.id ? null : l.id);
});
svg.append(g);
}
}
st.watch?.disconnect();
st.watch =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(() => {
if (!st.fitted && lg.boxes.length && view.clientWidth) {
st.fitted = true;
fit();
} else drawLines();
});
st.watch?.observe(view);
if (view.isConnected) {
if (!st.fitted && lg.boxes.length && view.clientWidth) {
st.fitted = true;
fit();
} else drawLines();
}
/* 끌기 셋 — 상자 · 판 · 선 */
function follow(
ev: PointerEvent,
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;
const mv = (m: PointerEvent): void => {
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
moved = true;
move(m);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
if (moved) {
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
}
end(u, moved);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
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 };
follow(
ev,
(m) => {
st.panX = p0.x + m.clientX - ev.clientX;
st.panY = p0.y + m.clientY - ev.clientY;
applyView();
},
() => undefined,
);
}
function dragLink(ev: PointerEvent, from: CfPort): void {
ev.stopPropagation();
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-cfdg__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-cfdg__pill");
const bx = hit?.closest<HTMLElement>(".m02-cfdg__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,
);
},
);
}
}
/** 시험 · 잇는 쪽이 판 상태를 읽을 때 — 확대 · 판 자리 */
export function viewOf(host: HTMLElement): { zoom: number; panX: number; panY: number } | null {
const st = states.get(host);
return st ? { zoom: st.zoom, panX: st.panX, panY: st.panY } : null;
}