/* ============================================================================= * M02_CF_Model.ts * 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나) * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; export type CfKind = "input" | "function" | "output"; export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; export type CfCost = "노무비" | "재료비" | "경비" | "값"; /** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */ export interface CfElementRef { ref: string; name: string; spec: string; unit: string; } /** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */ export interface CfVariable { name: string; type: "number" | "choice" | "text"; unit: string; choices?: (string | number)[]; range?: [number, number]; example: number | string | null; /** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */ cell?: string; } export interface CfInput { mode: "group" | "variable"; group?: CfGroup; elements: CfElementRef[]; /** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */ table?: { key: string; row?: number }; variables: CfVariable[]; } export interface CfFunc { /** 우리 스프레드시트 시트 하나 */ sheet: Sheet; /** 받는 알약 → 칸 */ inputs: { port: string; cell: string }[]; variables: CfVariable[]; /** 내보내는 알약 하나 */ output: { cell: string; name: string; unit: string }; } export interface CfOutput { cost: CfCost; unit: string; } export interface CfBox { id: string; kind: CfKind; name: string; color: string; /** 오른쪽 위 단위 — 기본 「-」 */ unit: string; x: number; y: number; input?: CfInput; func?: CfFunc; output?: CfOutput; } /** 알약 한쪽 끝 */ export interface CfPort { box: string; name: string; } export interface CfLink { id: string; from: CfPort; to: CfPort; } /** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ export interface CfUserElement { id: string; group: CfGroup; name: string; spec: string; unit: string; value: number | null; } export interface CfLogic { key: string | null; name: string; resultUnit: string; boxes: CfBox[]; links: CfLink[]; userElements: CfUserElement[]; } /** 알약 하나의 모습 */ export interface CfPill { name: string; unit: string; } const TEXT = { Input: ["입력", "Input"], Func: ["함수", "Function"], Output: ["출력", "Output"], Result: ["결과", "Result"], Value: ["값", "Value"], NoPill: ["알약이 없음", "No such pill"], FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Taken: ["이미 선이 들어온 자리", "This spot already has a line"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /** 종류별 기본 색 */ export const BOX_COLOR: Record = { input: "#2f80ed", function: "#7b4fd6", output: "#27ae60", }; /** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ export const OUTPUT_PORT = "값"; export const TABLE_PORT = "표"; const clone = (v: T): T => structuredClone(v); /** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ function nextId(prefix: string, ids: string[]): string { const nums = ids .filter((x) => x.startsWith(prefix)) .map((x) => Number(x.slice(prefix.length))) .filter((n) => Number.isInteger(n)); return `${prefix}${Math.max(0, ...nums) + 1}`; } /** 같은 이름이면 「 2」 「 3」 */ function uniqueName(used: string[], base: string): string { if (!used.includes(base)) return base; for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; } const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); /** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ export function updateBox( lg: CfLogic, id: string, patch: Partial>, ): CfLogic { return { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), }; } /* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; } /** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ export function addBox( lg: CfLogic, kind: CfKind, x: number, y: number, ): { lg: CfLogic; box: CfBox } { const id = nextId( "b", lg.boxes.map((b) => b.id), ); const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); const name = uniqueName( lg.boxes.map((b) => b.name), base, ); const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; if (kind === "function") box.func = { sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } }, inputs: [], variables: [], output: { cell: "B1", name: tl("Result"), unit: "-" }, }; if (kind === "output") box.output = { cost: "노무비", unit: "-" }; return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; } /** 상자 지우기 — 이어진 선도 지움 */ export function removeBox(lg: CfLogic, id: string): CfLogic { return { ...lg, boxes: lg.boxes.filter((b) => b.id !== id), links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), }; } export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { return updateBox(lg, id, { x, y }); } export function setColor(lg: CfLogic, id: string, color: string): CfLogic { return updateBox(lg, id, { color }); } /** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { const box = boxOf(lg, id); if (!box) return lg; const u = unit.trim() || "-"; const patch: Partial = { unit: u }; if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; if (box.output) patch.output = { ...box.output, unit: u }; return updateBox(lg, id, patch); } /* ── 알약 ───────────────────────────────────────────────────────────────── */ /** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name * 함수 = func.output.name 하나 · 출력 = 없음 */ export function portsOut(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; if (box.kind !== "input" || !box.input) return []; const inp = box.input; return [ ...(inp.mode === "group" ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) : []), ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), ]; } /** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ export function portsIn(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; return []; } /* ── 선 ─────────────────────────────────────────────────────────────────── */ /** from 상자에서 선을 따라 to 상자에 닿나 */ export function reaches(lg: CfLogic, from: string, to: string): boolean { const seen = new Set(); const walk = (id: string): boolean => { if (id === to) return true; if (seen.has(id)) return false; seen.add(id); return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); }; return walk(from); } /** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { const a = boxOf(lg, from.box); const b = boxOf(lg, to.box); if (!a || !b) return { lg, why: tl("NoPill") }; if (a.kind === "output") return { lg, why: tl("FromOutput") }; if (b.kind === "input") return { lg, why: tl("ToInput") }; if (a.id === b.id) return { lg, why: tl("Self") }; if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name)) return { lg, why: tl("NoPill") }; if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) return { lg, why: tl("Taken") }; const id = nextId( "l", lg.links.map((l) => l.id), ); return { lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, why: null, }; } export function unlink(lg: CfLogic, id: string): CfLogic { return { ...lg, links: lg.links.filter((l) => l.id !== id) }; }