Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
/* =============================================================================
|
||
* M02_MasterTemplete_Labor_Diagram.ts
|
||
* 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값)
|
||
* 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
|
||
* 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) ·
|
||
* 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { createButton, el } from "@ui/ui_template_elements";
|
||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||
import "./M02_MasterTemplete_Labor_Diagram.css";
|
||
|
||
const TEXT = {
|
||
Inputs: ["넣을 값", "Inputs"],
|
||
Logic: ["일위대가", "Unit price"],
|
||
Sum: ["모은 값", "Total"],
|
||
Empty: ["값 없음", "no value"],
|
||
Wait: ["계산 전", "not calculated"],
|
||
Stop: ["멈춤", "stopped"],
|
||
Zoom_In: ["크게", "Zoom in"],
|
||
Zoom_Out: ["작게", "Zoom out"],
|
||
Fit: ["한눈에", "Fit"],
|
||
Hint: [
|
||
"가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대",
|
||
"Middle button or Space + drag to move · Ctrl + wheel to zoom",
|
||
],
|
||
None: ["단가산출 로직이 없음", "No logics yet"],
|
||
Open: [
|
||
"누르면 이 로직을 단가산출 로직 화면에서 엶",
|
||
"Open this logic in the unit-price logic screen",
|
||
],
|
||
Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"],
|
||
Mats: ["자재목록", "Materials"],
|
||
Auto: ["자동값", "auto"],
|
||
Same: ["같은 이름의 넣을 값", "Same input name"],
|
||
Won: ["원", "KRW"],
|
||
Title: ["다이어그램", "Diagram"],
|
||
} as const;
|
||
|
||
const td = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||
|
||
/** 상세의 접는 칸 제목 */
|
||
export const diagramTitle = (): string => td("Title");
|
||
|
||
export const COST_BINS = ["노무비", "재료비", "경비"] as const;
|
||
type Cost = (typeof COST_BINS)[number];
|
||
|
||
export interface DiagramInput {
|
||
name: string;
|
||
unit: string;
|
||
/** 넣은 값 — 빈 글 = 값 없음 */
|
||
value: string;
|
||
/** 넣은 값이 없어 자동값을 보임 */
|
||
auto?: boolean;
|
||
}
|
||
|
||
/** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */
|
||
export type DiagramOut =
|
||
| { 노무비: number; 재료비: number; 경비: number; 계: number }
|
||
| { 값: number }
|
||
| { 멈춤: string }
|
||
| null;
|
||
|
||
export interface DiagramLogic {
|
||
key: string;
|
||
name: string;
|
||
/** 결과 단위 */
|
||
unit: string;
|
||
inputs: DiagramInput[];
|
||
out: DiagramOut;
|
||
/** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */
|
||
basis?: string;
|
||
/** 자재목록 카드 — 누름 없음 */
|
||
material?: boolean;
|
||
}
|
||
|
||
export interface DiagramData {
|
||
logics: DiagramLogic[];
|
||
}
|
||
|
||
export interface DiagramHandlers {
|
||
/** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */
|
||
openLogic(key: string): void;
|
||
/** 넣을 값 알약 누름 — 그 로직 입력 모달 */
|
||
focusInput(key: string, name: string): void;
|
||
}
|
||
|
||
/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */
|
||
|
||
type Money = { 노무비: number; 재료비: number; 경비: number; 계: number };
|
||
|
||
export interface LaborDiagramSource {
|
||
/** 담은 차례대로 */
|
||
rows: { 로직: string; 입력값: Record<string, string | number> }[];
|
||
/** 로직 입력 목록(`/logic-lib/inputs`) */
|
||
logics: {
|
||
로직: string;
|
||
이름: string;
|
||
결과단위?: string;
|
||
입력: { 이름: string; 단위?: string; 자동값?: unknown; 고르기이름?: Record<string, string> }[];
|
||
}[];
|
||
/** calc 답 · 아직이면 null */
|
||
calc: {
|
||
줄: {
|
||
로직: string;
|
||
기준: Money | null;
|
||
반영?: Money | null;
|
||
결과?: unknown;
|
||
까닭?: string | null;
|
||
}[];
|
||
자재: { 기준단가: number | null; 반영?: number | null }[];
|
||
} | null;
|
||
/** 담은 자재 수 — 0 이면 자재 카드 없음 */
|
||
mats: number;
|
||
}
|
||
|
||
const autoOf = (v: unknown): string =>
|
||
typeof v === "string" || typeof v === "number" ? String(v) : "";
|
||
|
||
export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
|
||
const logics: DiagramLogic[] = src.rows.map((r) => {
|
||
const def = src.logics.find((l) => l.로직 === r.로직);
|
||
const line = src.calc?.줄.find((l) => l.로직 === r.로직);
|
||
let out: DiagramOut = null;
|
||
let basis: string | undefined;
|
||
if (line?.까닭) out = { 멈춤: line.까닭 };
|
||
else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 };
|
||
else if (line && (line.반영 || line.기준)) {
|
||
out = { ...((line.반영 ?? line.기준) as Money) };
|
||
basis = line.반영 ? "반영" : "기준";
|
||
}
|
||
return {
|
||
key: r.로직,
|
||
name: def?.이름 ?? r.로직,
|
||
unit: def?.결과단위 ?? "",
|
||
inputs: (def?.입력 ?? []).map((x) => {
|
||
const mine = r.입력값[x.이름];
|
||
const has = mine !== undefined && mine !== "";
|
||
const raw = has ? String(mine) : autoOf(x.자동값);
|
||
return {
|
||
name: x.이름,
|
||
unit: x.단위 ?? "",
|
||
value: x.고르기이름?.[raw] ?? raw, // 85-1 원문번호 → 「이름 규격」
|
||
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
|
||
};
|
||
}),
|
||
out,
|
||
...(basis ? { basis } : {}),
|
||
};
|
||
});
|
||
if (src.mats > 0) {
|
||
const mats = src.calc?.자재 ?? null;
|
||
const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0;
|
||
logics.push({
|
||
key: "",
|
||
name: `${td("Mats")} ${src.mats}`,
|
||
unit: "",
|
||
inputs: [],
|
||
out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null,
|
||
...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}),
|
||
material: true,
|
||
});
|
||
}
|
||
return { logics };
|
||
}
|
||
|
||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||
|
||
const num = (v: number): string =>
|
||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||
|
||
const isMoney = (o: DiagramOut): o is Extract<DiagramOut, { 계: number }> => !!o && "계" in o;
|
||
const isValue = (o: DiagramOut): o is { 값: number } => !!o && "값" in o;
|
||
const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o;
|
||
|
||
const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0);
|
||
|
||
function summary(l: DiagramLogic): string {
|
||
if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
|
||
if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim();
|
||
if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`;
|
||
return td("Wait");
|
||
}
|
||
|
||
/* ── 판 (옮기기 · 확대 · 한눈에) ─────────────────────────────────────────── */
|
||
|
||
interface View {
|
||
x: number;
|
||
y: number;
|
||
zoom: number;
|
||
fitted: boolean;
|
||
}
|
||
|
||
interface State {
|
||
view: View;
|
||
viewport: HTMLElement;
|
||
world: HTMLElement;
|
||
label: HTMLElement;
|
||
space: boolean;
|
||
hover: boolean;
|
||
}
|
||
|
||
const states = new WeakMap<HTMLElement, State>();
|
||
const ZOOM_MIN = 0.2;
|
||
const ZOOM_MAX = 2.5;
|
||
|
||
function applyView(st: State): void {
|
||
const { x, y, zoom } = st.view;
|
||
st.world.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
|
||
st.label.textContent = `${Math.round(zoom * 100)}%`;
|
||
}
|
||
|
||
function zoomAt(st: State, factor: number, px?: number, py?: number): void {
|
||
const v = st.view;
|
||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.zoom * factor));
|
||
const cx = px ?? st.viewport.clientWidth / 2;
|
||
const cy = py ?? st.viewport.clientHeight / 2;
|
||
v.x = cx - ((cx - v.x) / v.zoom) * next;
|
||
v.y = cy - ((cy - v.y) / v.zoom) * next;
|
||
v.zoom = next;
|
||
applyView(st);
|
||
}
|
||
|
||
/** whole = 한눈에(통째로 보임) · 아니면 처음 — 너비에 맞추고 위에서 시작(글자가 너무 작아지지 않게) */
|
||
function fit(st: State, whole = true): void {
|
||
const w = st.world.offsetWidth;
|
||
const h = st.world.offsetHeight;
|
||
if (!w || !h) return;
|
||
const z = Math.min(
|
||
ZOOM_MAX,
|
||
Math.max(
|
||
whole ? ZOOM_MIN : 0.5,
|
||
Math.min(st.viewport.clientWidth / w, whole ? st.viewport.clientHeight / h : Infinity, 1),
|
||
),
|
||
);
|
||
st.view = {
|
||
zoom: z,
|
||
x: (st.viewport.clientWidth - w * z) / 2,
|
||
y: whole ? Math.max(0, (st.viewport.clientHeight - h * z) / 2) : 0,
|
||
fitted: true,
|
||
};
|
||
applyView(st);
|
||
}
|
||
|
||
function mountShell(host: HTMLElement): State {
|
||
const world = el("div", { className: "m02-labordg__world" });
|
||
const viewport = el("div", {
|
||
className: "m02-labordg__viewport",
|
||
attrs: { tabindex: "0" },
|
||
children: [world],
|
||
});
|
||
const label = el("span", { className: "m02-labordg__zoom" });
|
||
/** 아이콘 단추(128-1) — 공용 단추 + B05 · B06 확대 단추와 같은 기호 · 글은 title · aria-label */
|
||
const icon = (glyph: string, key: keyof typeof TEXT, onClick: () => void): HTMLButtonElement => {
|
||
const b = createButton({ label: glyph, variant: "ghost", size: "sm", onClick });
|
||
b.classList.add("m02-labordg__icon");
|
||
b.title = td(key);
|
||
b.setAttribute("aria-label", td(key));
|
||
return b;
|
||
};
|
||
const st: State = {
|
||
view: { x: 0, y: 0, zoom: 1, fitted: false },
|
||
viewport,
|
||
world,
|
||
label,
|
||
space: false,
|
||
hover: false,
|
||
};
|
||
host.replaceChildren(
|
||
el("div", {
|
||
className: "m02-labordg__bar",
|
||
children: [
|
||
icon("−", "Zoom_Out", () => zoomAt(st, 1 / 1.2)),
|
||
icon("+", "Zoom_In", () => zoomAt(st, 1.2)),
|
||
icon("⤢", "Fit", () => fit(st, true)),
|
||
label,
|
||
el("span", { className: "m02-labordg__hint", text: td("Hint") }),
|
||
],
|
||
}),
|
||
viewport,
|
||
);
|
||
// 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기
|
||
viewport.addEventListener("pointerdown", (e) => {
|
||
if (!(e.button === 1 || (e.button === 0 && st.space))) return;
|
||
e.preventDefault();
|
||
try {
|
||
viewport.setPointerCapture(e.pointerId);
|
||
} catch {
|
||
/* 잡지 못해도 옮기기는 됨 */
|
||
}
|
||
const from = { x: e.clientX - st.view.x, y: e.clientY - st.view.y };
|
||
viewport.classList.add("is-panning");
|
||
const move = (m: PointerEvent): void => {
|
||
st.view.x = m.clientX - from.x;
|
||
st.view.y = m.clientY - from.y;
|
||
applyView(st);
|
||
};
|
||
const up = (): void => {
|
||
viewport.classList.remove("is-panning");
|
||
viewport.removeEventListener("pointermove", move);
|
||
viewport.removeEventListener("pointerup", up);
|
||
viewport.removeEventListener("pointercancel", up);
|
||
};
|
||
viewport.addEventListener("pointermove", move);
|
||
viewport.addEventListener("pointerup", up);
|
||
viewport.addEventListener("pointercancel", up);
|
||
});
|
||
viewport.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
if (!e.ctrlKey) return;
|
||
e.preventDefault();
|
||
const r = viewport.getBoundingClientRect();
|
||
zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top);
|
||
},
|
||
{ passive: false },
|
||
);
|
||
new ResizeObserver(() => {
|
||
if (!st.view.fitted && world.offsetWidth) fit(st, false);
|
||
}).observe(viewport);
|
||
viewport.addEventListener("pointerenter", () => (st.hover = true));
|
||
viewport.addEventListener("pointerleave", () => (st.hover = false));
|
||
const keys = (e: KeyboardEvent, on: boolean): void => {
|
||
if (e.code !== "Space" || !st.hover || !host.isConnected) return;
|
||
st.space = on;
|
||
viewport.classList.toggle("is-space", on);
|
||
e.preventDefault();
|
||
};
|
||
window.addEventListener("keydown", (e) => keys(e, true));
|
||
window.addEventListener("keyup", (e) => keys(e, false));
|
||
// 같은 이름 넣을 값 — 마우스를 올리면 같은 이름끼리 진하게
|
||
const hot = (e: Event, on: boolean): void => {
|
||
const name = (e.target as HTMLElement).closest<HTMLElement>("[data-name]")?.dataset.name;
|
||
if (name === undefined) return;
|
||
for (const n of world.querySelectorAll<Element>(`[data-name]`))
|
||
if ((n as HTMLElement).dataset.name === name) n.classList.toggle("is-hot", on);
|
||
};
|
||
world.addEventListener("pointerover", (e) => hot(e, true));
|
||
world.addEventListener("pointerout", (e) => hot(e, false));
|
||
return st;
|
||
}
|
||
|
||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||
|
||
const SVG = "http://www.w3.org/2000/svg";
|
||
|
||
function path(d: string, cls: string, name?: string): SVGPathElement {
|
||
const p = document.createElementNS(SVG, "path");
|
||
p.setAttribute("d", d);
|
||
p.setAttribute("class", cls);
|
||
if (name !== undefined) p.dataset.name = name;
|
||
return p;
|
||
}
|
||
|
||
/** 다이어그램 그리기 — 칸은 고정 세 줄(넣을 값 · 로직 카드 · 비용 구분) · 자리는 저절로 */
|
||
export function renderComboDiagram(
|
||
host: HTMLElement,
|
||
data: DiagramData,
|
||
on: DiagramHandlers,
|
||
): void {
|
||
host.classList.add("m02-labordg");
|
||
let st = states.get(host);
|
||
if (!st || !st.viewport.isConnected) {
|
||
st = mountShell(host);
|
||
states.set(host, st);
|
||
}
|
||
const { world } = st;
|
||
if (!data.logics.length) {
|
||
world.replaceChildren(el("p", { className: "m02-labordg__none", text: td("None") }));
|
||
return;
|
||
}
|
||
const hasValue = data.logics.some((l) => isValue(l.out));
|
||
const bins: (Cost | "값")[] = [...COST_BINS, ...(hasValue ? (["값"] as const) : [])];
|
||
const grid = el("div", { className: "m02-labordg__grid" });
|
||
grid.style.gridTemplateColumns = `max-content 4.5rem 15rem 5rem repeat(${bins.length}, 11rem)`;
|
||
const head = (text: string, col: number, cls = ""): HTMLElement =>
|
||
el("div", {
|
||
className: `m02-labordg__head ${cls}`,
|
||
text,
|
||
attrs: { style: `grid-column:${col};grid-row:1` },
|
||
});
|
||
grid.append(head(td("Inputs"), 1), head(td("Logic"), 3));
|
||
bins.forEach((b, i) => grid.append(head(b, 5 + i, `is-bin is-${b}`)));
|
||
|
||
const sums = new Map<string, number>(bins.map((b) => [b, 0]));
|
||
const cards: HTMLElement[] = [];
|
||
const groups: HTMLElement[] = [];
|
||
const cells: HTMLElement[][] = [];
|
||
data.logics.forEach((l, i) => {
|
||
const row = i + 2;
|
||
const group = el("div", {
|
||
className: "m02-labordg__group",
|
||
attrs: { style: `grid-column:1;grid-row:${row}` },
|
||
children: l.inputs.length
|
||
? l.inputs.map((x) => {
|
||
const empty = x.value.trim() === "";
|
||
const pill = el("button", {
|
||
className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`,
|
||
attrs: {
|
||
type: "button",
|
||
"data-name": x.name,
|
||
title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"),
|
||
},
|
||
children: [
|
||
el("span", { className: "m02-labordg__pill-name", text: x.name }),
|
||
el("span", {
|
||
className: "m02-labordg__pill-value",
|
||
text: empty ? td("Empty") : `${x.value}${x.unit ? ` ${x.unit}` : ""}`,
|
||
}),
|
||
],
|
||
});
|
||
pill.addEventListener("click", () => on.focusInput(l.key, x.name));
|
||
return pill;
|
||
})
|
||
: [],
|
||
});
|
||
const card = el("button", {
|
||
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`,
|
||
attrs: {
|
||
type: "button",
|
||
style: `grid-column:3;grid-row:${row}`,
|
||
...(l.material ? {} : { title: td("Open") }),
|
||
},
|
||
children: [
|
||
el("span", { className: "m02-labordg__card-key", text: l.key }),
|
||
el("strong", { className: "m02-labordg__card-name", text: l.name }),
|
||
el("span", { className: "m02-labordg__card-unit", text: l.unit }),
|
||
el("span", { className: "m02-labordg__card-out", text: summary(l) }),
|
||
],
|
||
});
|
||
if (!l.material) card.addEventListener("click", () => on.openLogic(l.key));
|
||
const mine = bins.map((b, c) => {
|
||
const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b);
|
||
sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount));
|
||
const shown =
|
||
b === "값"
|
||
? isValue(l.out)
|
||
? `${num(l.out.값)} ${l.unit}`.trim()
|
||
: ""
|
||
: formatMoney(amount);
|
||
return el("div", {
|
||
className: `m02-labordg__cell is-bin is-${b}${amount === 0 ? " is-zero" : ""}`,
|
||
attrs: { style: `grid-column:${5 + c};grid-row:${row}` },
|
||
children: [
|
||
el("span", { className: "m02-labordg__cell-name", text: l.name }),
|
||
el("span", { className: "m02-labordg__cell-amount", text: shown }),
|
||
],
|
||
});
|
||
});
|
||
groups.push(group);
|
||
cards.push(card);
|
||
cells.push(mine);
|
||
grid.append(group, card, ...mine);
|
||
});
|
||
const total = data.logics.length + 2;
|
||
bins.forEach((b, i) =>
|
||
grid.append(
|
||
el("div", {
|
||
className: `m02-labordg__cell is-bin is-${b} is-total`,
|
||
attrs: { style: `grid-column:${5 + i};grid-row:${total}` },
|
||
children: [
|
||
el("span", { className: "m02-labordg__cell-name", text: td("Sum") }),
|
||
el("span", {
|
||
className: "m02-labordg__cell-amount",
|
||
text: b === "값" ? "" : formatMoney(sums.get(b) ?? 0),
|
||
}),
|
||
],
|
||
}),
|
||
),
|
||
);
|
||
|
||
const svg = document.createElementNS(SVG, "svg");
|
||
svg.setAttribute("class", "m02-labordg__lines");
|
||
world.replaceChildren(svg, grid);
|
||
|
||
// 선 — 칸 자리를 재서 그림(판 확대와 상관없이 판 안 좌표)
|
||
const scale = st.view.zoom || 1;
|
||
const base = grid.getBoundingClientRect();
|
||
const at = (e: Element): { l: number; r: number; t: number; b: number; m: number } => {
|
||
const r = e.getBoundingClientRect();
|
||
const t = (r.top - base.top) / scale;
|
||
const h = r.height / scale;
|
||
return {
|
||
l: (r.left - base.left) / scale,
|
||
r: (r.right - base.left) / scale,
|
||
t,
|
||
b: t + h,
|
||
m: t + h / 2,
|
||
};
|
||
};
|
||
svg.setAttribute("width", String(grid.offsetWidth));
|
||
svg.setAttribute("height", String(grid.offsetHeight));
|
||
const curve = (x1: number, y1: number, x2: number, y2: number): string => {
|
||
const dx = (x2 - x1) / 2;
|
||
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
|
||
};
|
||
data.logics.forEach((_l, i) => {
|
||
const c = at(cards[i]);
|
||
const g = at(groups[i]);
|
||
svg.append(path(curve(g.r, c.m, c.l, c.m), "m02-labordg__wire"));
|
||
cells[i].forEach((cell, k) => {
|
||
const t = at(cell);
|
||
const zero = cell.classList.contains("is-zero");
|
||
svg.append(
|
||
path(curve(c.r, c.m, t.l, t.m), `m02-labordg__out is-${bins[k]}${zero ? " is-zero" : ""}`),
|
||
);
|
||
});
|
||
});
|
||
// 같은 이름 넣을 값 — 점선(검사 없음 · 보이기만)
|
||
const byName = new Map<string, Element[]>();
|
||
grid.querySelectorAll(".m02-labordg__pill").forEach((p) => {
|
||
const name = (p as HTMLElement).dataset.name ?? "";
|
||
byName.set(name, [...(byName.get(name) ?? []), p]);
|
||
});
|
||
let lane = 0;
|
||
for (const [name, list] of byName) {
|
||
if (list.length < 2) continue;
|
||
lane++;
|
||
const rects = list.map(at);
|
||
const x = Math.min(...rects.map((r) => r.l)) - 8 - lane * 6;
|
||
for (let k = 1; k < rects.length; k++) {
|
||
const a = rects[k - 1];
|
||
const b = rects[k];
|
||
svg.append(
|
||
path(`M${a.l},${a.m} C${x},${a.m} ${x},${b.m} ${b.l},${b.m}`, "m02-labordg__same", name),
|
||
);
|
||
}
|
||
}
|
||
svg.style.overflow = "visible";
|
||
// 처음에는 너비에 맞춤 — 아직 안 보이는 칸(크기 0)이면 보이는 때 맞춤
|
||
if (!st.view.fitted) fit(st, false);
|
||
else applyView(st);
|
||
}
|