Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts
T

535 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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);
}