- 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
641 lines
23 KiB
TypeScript
641 lines
23 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,
|
|
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 breaks = opts.breaks ?? [];
|
|
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" : "",
|
|
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))),
|
|
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"),
|
|
}),
|
|
],
|
|
}),
|
|
lg.boxes.length ? view : el("p", { className: "m02-dfdg__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 },
|
|
);
|
|
// 판 옮기기 = 가운데 단추 끌기(어디서나) · 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());
|
|
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-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();
|
|
return toWorld(dir === "out" ? 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;
|
|
};
|
|
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 g = add(svg, "g", {
|
|
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`,
|
|
"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);
|
|
if (rule) {
|
|
const tag = add(g, "g", { class: "m02-dfdg__rule" });
|
|
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
|
const text = add(tag, "text", {
|
|
x: mid.x.toFixed(1),
|
|
y: mid.y.toFixed(1),
|
|
"dominant-baseline": "central",
|
|
"text-anchor": "middle",
|
|
});
|
|
text.textContent = rule;
|
|
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
|
|
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
|
box.setAttribute(k, v.toFixed(1)),
|
|
);
|
|
}
|
|
const broken = breaks.filter((k) => k.link === l.id);
|
|
if (broken.length) {
|
|
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),
|
|
cy: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
|
r: "8",
|
|
});
|
|
const t = add(badge, "text", {
|
|
x: mid.x.toFixed(1),
|
|
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
|
"dominant-baseline": "central",
|
|
"text-anchor": "middle",
|
|
});
|
|
t.textContent = "!";
|
|
add(badge, "title", {}).textContent = why;
|
|
}
|
|
g.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
const why = broken.map((k) => k.text).join(" · ");
|
|
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
|
|
});
|
|
}
|
|
}
|
|
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 follow(
|
|
ev: PointerEvent,
|
|
move: (m: PointerEvent) => void,
|
|
end: (u: PointerEvent, moved: boolean) => void,
|
|
): void {
|
|
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 {
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|