/* ============================================================================= * 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 }[]; /** 로직 입력 목록(`/logic-lib/inputs`) */ logics: { 로직: string; 이름: string; 결과단위?: string; 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; }[]; /** 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 !== ""; return { name: x.이름, unit: x.단위 ?? "", value: has ? String(mine) : autoOf(x.자동값), ...(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 => !!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(); 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" }); 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: [ createButton({ label: td("Zoom_Out"), variant: "ghost", size: "sm", onClick: () => zoomAt(st, 1 / 1.2), }), createButton({ label: td("Zoom_In"), variant: "ghost", size: "sm", onClick: () => zoomAt(st, 1.2), }), createButton({ label: td("Fit"), variant: "ghost", size: "sm", onClick: () => 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("[data-name]")?.dataset.name; if (name === undefined) return; for (const n of world.querySelectorAll(`[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(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(); 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); }