- 수는 천 단위 쉼표 · 소수 셋째 자리까지 · 끝 0 지움 · 글은 그대로 - 저절로 맞춤이 넘치면 왼쪽 위부터 보임 · 축소 단추로는 더 줄임 - 알약 대신 길(Model 준비 전) 걷음 · 682줄 · CostFlow 전부 검사 초록 - ORCA 견본 메쌓기: 97,882.512 · 268,908 · 0.364 · 표 · 40줄 · 처음 확대 0.9 · 한눈에 0.9 · 축소 0.75 · 알약 글 10.8px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
683 lines
25 KiB
TypeScript
683 lines
25 KiB
TypeScript
/* =============================================================================
|
|
* 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 pill with that name — drop on a pill",
|
|
],
|
|
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
|
|
From: ["어디서", "From"],
|
|
To: ["어디로", "To"],
|
|
Value: ["지금 값", "Now"],
|
|
PlusIn: [
|
|
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
|
|
"Drop an output pill here to add an input",
|
|
],
|
|
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
|
Hint: [
|
|
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
|
|
"Drag head to move · drag space to pan",
|
|
],
|
|
} 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[] } {
|
|
return { ins: portsIn(box), outs: portsOut(box) };
|
|
}
|
|
|
|
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
|
|
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);
|
|
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
|
|
if (box?.kind === "function" && target.name === null)
|
|
return { to: { box: box.id, name: from.name } };
|
|
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 const FIT_MIN = 0.9;
|
|
|
|
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
|
|
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.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
|
|
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
|
|
const place = (size: number, from: number, to: number): number =>
|
|
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
|
|
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
|
|
}
|
|
|
|
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";
|
|
|
|
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
|
|
export const showValue = (v: unknown): string => {
|
|
if (v === null || v === undefined || v === "") return "";
|
|
if (typeof v === "number")
|
|
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
|
|
if (Array.isArray(v)) {
|
|
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
|
|
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
|
|
}
|
|
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 plusSlot = (b: CfBox): HTMLElement => {
|
|
const slot = el("button", {
|
|
className: `m02-cfdg__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 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")),
|
|
...(b.kind === "function" ? [plusSlot(b)] : []),
|
|
],
|
|
}),
|
|
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 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.name;
|
|
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.name]) || "—"}`;
|
|
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
|
|
tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") }));
|
|
if (p.dir === "out")
|
|
tools.push(
|
|
sm(
|
|
"Connect",
|
|
() => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()),
|
|
"filled",
|
|
),
|
|
);
|
|
} else if (selLink) {
|
|
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
|
|
tools.push(
|
|
el("span", { text: line }),
|
|
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
|
|
);
|
|
} else if (selBox) {
|
|
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
|
|
}
|
|
|
|
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" }),
|
|
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-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);
|
|
}
|
|
}
|
|
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 {
|
|
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|