Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
/* 시트 로직 — 위 가로 다이어그램 */
|
||||
.m01-sldg {
|
||||
--sldg-in: var(--color-accent);
|
||||
--sldg-out: #c2610c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.m01-sldg__note {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-sldg__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-sldg__menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */
|
||||
.m01-sldg__band {
|
||||
max-height: 33vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-sldg__inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(11rem, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
min-width: max-content;
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-sldg__lines {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__col {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.m01-sldg__col-head {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* 상자 */
|
||||
.m01-sldg__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__box:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m01-sldg__box.is-selected {
|
||||
border: 2px solid var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.m01-sldg__box-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m01-sldg__kind {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-sldg__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pills {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-sldg__ins,
|
||||
.m01-sldg__outs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__outs {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */
|
||||
.m01-sldg__pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 10rem;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--sldg-in);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface));
|
||||
color: var(--sldg-in);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-out {
|
||||
border-color: var(--sldg-out);
|
||||
background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface));
|
||||
color: var(--sldg-out);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-empty {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
box-shadow: 0 0 0 2px var(--sldg-in);
|
||||
animation: m01-sldg-blink 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes m01-sldg-blink {
|
||||
to {
|
||||
background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__pill-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 선 */
|
||||
.m01-sldg__edge {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.m01-sldg__wire {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-strong .m01-sldg__wire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-dim .m01-sldg__wire {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.m01-sldg__drag {
|
||||
fill: none;
|
||||
stroke: var(--sldg-out);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Diagram.ts
|
||||
* 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」)
|
||||
* 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) ·
|
||||
* 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG.
|
||||
* 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect ·
|
||||
* 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」.
|
||||
* 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model";
|
||||
import "./M01_SL_Diagram.css";
|
||||
|
||||
const TEXT = {
|
||||
Stage0: ["입력값", "Inputs"],
|
||||
Stage1: ["표", "Tables"],
|
||||
Stage2: ["계산", "Calculations"],
|
||||
Stage3: ["항목", "Items"],
|
||||
Stage4: ["결과", "Result"],
|
||||
K_input: ["입력값", "Input"],
|
||||
K_const: ["고정값", "Fixed value"],
|
||||
K_table: ["표", "Table"],
|
||||
K_calc: ["계산", "Calculation"],
|
||||
K_item: ["항목", "Item"],
|
||||
K_logic: ["다른 로직", "Other logic"],
|
||||
K_result: ["결과", "Result"],
|
||||
Add: ["+ 상자", "+ Box"],
|
||||
AddTitle: ["더할 상자 종류", "Kind of box to add"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"],
|
||||
NoOut: ["내보내는 알약이 없음", "No output pills"],
|
||||
NoIn: ["받는 알약이 없음", "No input pills"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"],
|
||||
NoMatch: [
|
||||
"이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요",
|
||||
"No input pill with the same name — drop on the pill itself",
|
||||
],
|
||||
Taken: [
|
||||
"이미 이어진 입력 알약 — 먼저 그 선을 끊으세요",
|
||||
"That input already has a line — disconnect it first",
|
||||
],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
} as const;
|
||||
|
||||
const td = (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 STAGE_OF: Record<SlKind, number> = {
|
||||
input: 0,
|
||||
const: 0,
|
||||
logic: 0,
|
||||
table: 1,
|
||||
calc: 2,
|
||||
item: 3,
|
||||
result: 4,
|
||||
};
|
||||
|
||||
const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"];
|
||||
const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT);
|
||||
|
||||
export interface DiagramOn {
|
||||
select(id: string | null): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */
|
||||
connect(from: SlEnd, to: SlEnd): string | null | void;
|
||||
disconnect(edgeId: string): void;
|
||||
add(kind: SlKind, stage: number): void;
|
||||
remove(nodeId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
}
|
||||
|
||||
/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */
|
||||
export function layout(nodes: SlNode[]): SlNode[][] {
|
||||
const cols: SlNode[][] = [[], [], [], [], []];
|
||||
for (const n of nodes) {
|
||||
const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind];
|
||||
cols[Math.min(4, Math.max(0, s ?? 0))].push(n);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
sl: SheetLogic,
|
||||
from: SlEnd,
|
||||
target: { node: string; pill: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: SlEnd } | { why: string } {
|
||||
if (!target) return { why: td("NotIn") };
|
||||
if (target.node === from.node) return { why: td("SameBox") };
|
||||
if (target.dir === "out") return { why: td("NotIn") };
|
||||
const node = sl.nodes.find((n) => n.id === target.node);
|
||||
if (!node?.pillsIn.length) return { why: td("NoIn") };
|
||||
const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name;
|
||||
if (!name) return { why: td("NoMatch") };
|
||||
if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name))
|
||||
return { why: td("Taken") };
|
||||
return { to: { node: node.id, pill: name } };
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface HostState {
|
||||
selected: string | null;
|
||||
/** 잇기 중 — 출력 알약 */
|
||||
linking: SlEnd | null;
|
||||
/** 고른 알약(정보 줄) */
|
||||
pill: { node: string; pill: string; dir: "in" | "out" } | null;
|
||||
note: string;
|
||||
scroll: number;
|
||||
/** 선 다시 그리기 — 띠 크기 바뀜 */
|
||||
watch?: ResizeObserver;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const edgeTouches = (e: SlEdge, node: string): boolean =>
|
||||
e.from.node === node || e.to.node === node;
|
||||
|
||||
export function renderDiagram(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
on: DiagramOn,
|
||||
opts: DiagramOpts = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 };
|
||||
if (opts.selected !== undefined) st.selected = opts.selected;
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? "";
|
||||
const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected });
|
||||
const select = (id: string | null): void => {
|
||||
st.selected = id;
|
||||
st.pill = null;
|
||||
st.note = "";
|
||||
on.select(id);
|
||||
redraw();
|
||||
};
|
||||
const tryConnect = (from: SlEnd, target: Parameters<typeof resolveDrop>[2]): void => {
|
||||
const got = resolveDrop(sl, from, target);
|
||||
st.linking = null;
|
||||
if ("why" in got) st.note = got.why;
|
||||
else st.note = on.connect(from, got.to) || "";
|
||||
redraw();
|
||||
};
|
||||
|
||||
/* 상자 */
|
||||
const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => {
|
||||
const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name);
|
||||
const linking = st.linking;
|
||||
const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir;
|
||||
const b = el("button", {
|
||||
className: [
|
||||
"m01-sldg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
linking && dir === "in" && n.id !== linking.node ? "is-target" : "",
|
||||
].join(" "),
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-node": n.id,
|
||||
"data-pill": name,
|
||||
"data-dir": dir,
|
||||
title: unit ? `${name} (${unit})` : name,
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }),
|
||||
el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking && dir === "in")
|
||||
return tryConnect(st.linking, { node: n.id, pill: name, dir });
|
||||
st.pill = chosen ? null : { node: n.id, pill: name, dir };
|
||||
st.linking = null;
|
||||
st.note = "";
|
||||
redraw();
|
||||
});
|
||||
if (dir === "out")
|
||||
b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name }));
|
||||
return b;
|
||||
};
|
||||
|
||||
const boxEl = (n: SlNode): HTMLElement => {
|
||||
const box = el("div", {
|
||||
className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join(
|
||||
" ",
|
||||
),
|
||||
attrs: {
|
||||
"data-node": n.id,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${kindLabel(n.kind)} · ${n.name}`,
|
||||
},
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__box-head",
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }),
|
||||
el("strong", { className: "m01-sldg__name", text: n.name }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__pills",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__ins",
|
||||
children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)),
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__outs",
|
||||
children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
box.addEventListener("click", () => {
|
||||
if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null });
|
||||
select(st.selected === n.id ? null : n.id);
|
||||
});
|
||||
box.addEventListener("keydown", (ev) => {
|
||||
if (ev.target !== box) return;
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
box.click();
|
||||
}
|
||||
});
|
||||
return box;
|
||||
};
|
||||
|
||||
const cols = layout(sl.nodes);
|
||||
const inner = el("div", {
|
||||
className: "m01-sldg__inner",
|
||||
children: cols.map((list, i) =>
|
||||
el("div", {
|
||||
className: "m01-sldg__col",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__col-head",
|
||||
text: td(`Stage${i}` as keyof typeof TEXT),
|
||||
}),
|
||||
...list.map(boxEl),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("m01-sldg__lines");
|
||||
inner.prepend(svg);
|
||||
inner.addEventListener("click", (ev) => {
|
||||
if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) {
|
||||
st.linking = null;
|
||||
select(null);
|
||||
}
|
||||
});
|
||||
|
||||
/* 위 줄 — 더하기 · 고른 것 · 할 일 */
|
||||
const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } });
|
||||
addMenu.append(
|
||||
el("span", { className: "m01-sldg__muted", text: td("AddTitle") }),
|
||||
...KINDS.map((k) =>
|
||||
createButton({
|
||||
label: kindLabel(k),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
addMenu.hidden = true;
|
||||
on.add(k, STAGE_OF[k]);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const addBtn = createButton({
|
||||
label: td("Add"),
|
||||
size: "sm",
|
||||
onClick: () => (addMenu.hidden = !addMenu.hidden),
|
||||
});
|
||||
const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null;
|
||||
const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (st.linking) {
|
||||
tools.push(
|
||||
el("span", { text: td("PickTarget", { v: st.linking.pill }) }),
|
||||
createButton({
|
||||
label: td("Cancel"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => ((st.linking = null), redraw()),
|
||||
}),
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
const linked = sl.edges.filter((e) =>
|
||||
p.dir === "in"
|
||||
? e.to.node === p.node && e.to.pill === p.pill
|
||||
: e.from.node === p.node && e.from.pill === p.pill,
|
||||
);
|
||||
const where = linked
|
||||
.map((e) =>
|
||||
p.dir === "in"
|
||||
? `${nodeName(e.from.node)} · ${e.from.pill}`
|
||||
: `${nodeName(e.to.node)} · ${e.to.pill}`,
|
||||
)
|
||||
.join(", ");
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: [
|
||||
`${p.pill}`,
|
||||
`${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`,
|
||||
where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
);
|
||||
if (p.dir === "out")
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Connect"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
st.linking = { node: p.node, pill: p.pill };
|
||||
st.pill = null;
|
||||
redraw();
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (selEdge) {
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: td("LineOf", {
|
||||
a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`,
|
||||
b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`,
|
||||
}),
|
||||
}),
|
||||
createButton({
|
||||
label: td("Disconnect"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => on.disconnect(selEdge.id),
|
||||
}),
|
||||
);
|
||||
} else if (selNode) {
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Remove"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => void askRemove(selNode),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const askRemove = async (n: SlNode): Promise<void> => {
|
||||
if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id);
|
||||
};
|
||||
|
||||
const band = el("div", { className: "m01-sldg__band", children: [inner] });
|
||||
const root = el("div", {
|
||||
className: `m01-sldg${st.linking ? " is-linking" : ""}`,
|
||||
attrs: { tabindex: "-1" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__bar",
|
||||
children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })],
|
||||
}),
|
||||
addMenu,
|
||||
sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("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 (selEdge) on.disconnect(selEdge.id);
|
||||
else if (selNode) void askRemove(selNode);
|
||||
});
|
||||
band.addEventListener("scroll", () => (st.scroll = band.scrollLeft));
|
||||
host.replaceChildren(root);
|
||||
band.scrollLeft = st.scroll;
|
||||
|
||||
/* 선 — 붙인 뒤 알약 자리를 재서 그림 */
|
||||
const drawLines = (): void => {
|
||||
const box = inner.getBoundingClientRect();
|
||||
svg.setAttribute("width", String(inner.scrollWidth));
|
||||
svg.setAttribute("height", String(inner.scrollHeight));
|
||||
svg.replaceChildren();
|
||||
const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => {
|
||||
const pill = inner.querySelector<HTMLElement>(
|
||||
`.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`,
|
||||
);
|
||||
if (!pill) return null;
|
||||
const r = pill.getBoundingClientRect();
|
||||
return {
|
||||
x: (dir === "out" ? r.right : r.left) - box.left,
|
||||
y: r.top + r.height / 2 - box.top,
|
||||
};
|
||||
};
|
||||
for (const e of sl.edges) {
|
||||
const a = at(e.from, "out");
|
||||
const b = at(e.to, "in");
|
||||
if (!a || !b) continue;
|
||||
const dx = Math.max(30, 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 === e.id || (selNode !== null && edgeTouches(e, selNode.id));
|
||||
const dim = st.selected !== null && !strong;
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`);
|
||||
g.setAttribute("data-edge", e.id);
|
||||
g.setAttribute("data-x1", a.x.toFixed(1));
|
||||
g.setAttribute("data-y1", a.y.toFixed(1));
|
||||
g.setAttribute("data-x2", b.x.toFixed(1));
|
||||
g.setAttribute("data-y2", b.y.toFixed(1));
|
||||
for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) {
|
||||
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
p.setAttribute("d", d);
|
||||
p.setAttribute("class", cls);
|
||||
g.append(p);
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(st.selected === e.id ? null : e.id);
|
||||
});
|
||||
svg.append(g);
|
||||
}
|
||||
};
|
||||
// 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기)
|
||||
st.watch?.disconnect();
|
||||
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines);
|
||||
st.watch?.observe(inner);
|
||||
if (inner.isConnected) drawLines();
|
||||
|
||||
/* 끌어 잇기 */
|
||||
function startDrag(ev: PointerEvent, from: SlEnd): void {
|
||||
if (ev.button !== 0) return;
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
const box = inner.getBoundingClientRect();
|
||||
const r = pill.getBoundingClientRect();
|
||||
const x0 = r.right - box.left;
|
||||
const y0 = r.top + r.height / 2 - box.top;
|
||||
let line: SVGPathElement | null = null;
|
||||
const move = (m: PointerEvent): void => {
|
||||
const x = m.clientX - box.left;
|
||||
const y = m.clientY - box.top;
|
||||
if (!line && Math.hypot(x - x0, y - y0) < 6) return;
|
||||
if (!line) {
|
||||
line = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
line.setAttribute("class", "m01-sldg__drag");
|
||||
svg.append(line);
|
||||
}
|
||||
line.setAttribute("d", `M${x0},${y0} L${x},${y}`);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (!line) return; // 끌지 않음 = 누름(click 이 처리)
|
||||
line.remove();
|
||||
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
|
||||
const pillHit = hit?.closest<HTMLElement>(".m01-sldg__pill");
|
||||
const boxHit = hit?.closest<HTMLElement>(".m01-sldg__box");
|
||||
const target = pillHit
|
||||
? {
|
||||
node: pillHit.dataset.node ?? "",
|
||||
pill: pillHit.dataset.pill ?? null,
|
||||
dir: (pillHit.dataset.dir as "in" | "out") ?? null,
|
||||
}
|
||||
: boxHit
|
||||
? { node: boxHit.dataset.node ?? "", pill: null, dir: null }
|
||||
: null;
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
tryConnect(from, target);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user