/* ============================================================================= * M02_DF_FuncPanel.ts * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로 * 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음) * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { CalcValues, SpreadsheetHandle, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model"; import "./M02_DF_FuncPanel.css"; const TEXT = { Unit: ["단위", "Unit"], Cell: ["고른 칸", "Selected cell"], Got: ["받은 알약", "Received pills"], GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], Placed: ["놓임", "placed"], Free: ["아직 안 놓은 알약", "not placed yet"], Busy: ["이미 쓰는 칸", "That cell is already in use"], Locked: [ "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", "This cell is filled by the wire — cannot edit", ], Vars: ["변수", "Variables"], MakeVar: ["변수 만들기", "Make variable"], NoVars: ["아직 변수가 없음", "No variables yet"], VarName: ["이름", "Name"], VarType: ["갈래", "Type"], Number: ["숫자", "Number"], Choice: ["선택", "Choice"], Text: ["글", "Text"], VarUnit: ["단위", "Unit"], VarMin: ["최소", "Min"], VarMax: ["최대", "Max"], VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], VarExample: ["예시값", "Example value"], VarCell: ["붙을 칸", "Attach to"], Make: ["만들기", "Make"], Cancel: ["취소", "Cancel"], Remove: ["지움", "Remove"], NeedName: ["이름을 적음", "Enter a name"], SameName: ["같은 이름이 이미 있음", "That name already exists"], NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], OutBtn: ["출력 칸으로", "Make output cell"], OutNow: ["출력", "Output"], OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], Frames: ["틀", "Templates"], FrameMul: ["곱하기", "Multiply"], FrameMore: ["할증", "Markup"], FrameDiv: ["나누기", "Divide"], FrameIf: ["만약", "If"], FrameMin: ["최소", "Min"], FrameMax: ["최대", "Max"], FrameUp: ["올림", "Round up"], FrameLook: ["표에서 찾기", "Look up in table"], FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], ReadOnly: ["읽기만", "Read only"], Loading: ["시트를 불러오는 중", "Loading sheet"], Problem: ["⚠ ", "⚠ "], Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], Err_DIV0: ["0으로 나눔", "divides by zero"], Err_NAME: ["알 수 없는 이름", "unknown name"], Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], Err_NA: ["찾는 값이 없음", "value not found"], Err_OTHER: ["수식이 틀림", "formula is wrong"], } as const; const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; /** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ const FRAMES: [keyof typeof TEXT, string][] = [ ["FrameMul", "=B1*B2"], ["FrameMore", "=B1*(1+B2/100)"], ["FrameDiv", "=B1/B2"], ["FrameIf", '=IF(B3="급",10,0)'], ["FrameMin", "=MIN(B1,B2)"], ["FrameMax", "=MAX(B1,B2)"], ["FrameUp", "=ROUNDUP(B4,3)"], ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], ]; const ERR_KEY: Record = { "#DIV/0!": "Err_DIV0", "#NAME?": "Err_NAME", "#REF!": "Err_REF", "#VALUE!": "Err_VALUE", "#CYCLE!": "Err_CYCLE", "#N/A": "Err_NA", }; type Handlers = { change(lg: CfLogic): void }; interface VarForm { name: string; type: CfVariable["type"]; unit: string; min: string; max: string; choices: string; example: string; why: string; } interface State { boxId: string; lg: CfLogic; on: Handlers; readOnly: boolean; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ json: string; cell: string; form: VarForm | null; parts: { head: HTMLElement; got: HTMLElement; vars: HTMLElement; bar: HTMLElement; msg: HTMLElement; sheet: HTMLElement; }; observer: MutationObserver | null; redraw(): void; } const states = new WeakMap(); let sheetModule: Promise | null = null; /** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ const loadSheet = (): Promise => (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId); const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; const bookOf = (func: CfFunc): Workbook => ({ 종류: "통합문서", 판: 1, 열: "cf", 서식: [{}], 시트: [structuredClone(func.sheet)], 활성: func.sheet.id, }); const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet); /* ------------------------------ 바꾸기 ------------------------------ */ function patchBox(st: State, make: (b: CfBox) => CfBox): void { const next: CfLogic = { ...st.lg, boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)), }; st.lg = next; st.on.change(next); } const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void => patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b)); const usedCells = (f: CfFunc): Map => { const m = new Map(); for (const i of f.inputs) m.set(i.cell, i.port); for (const v of f.variables) if (v.cell) m.set(v.cell, v.name); if (f.output.cell) m.set(f.output.cell, f.output.name); return m; }; function placePort(st: State, port: string, cell: string): void { const f = funcOf(st); if (!f || st.readOnly) return; const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell); if (other) return void showToast(tf("Busy"), "error"); patchFunc(st, (x) => ({ ...x, inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }], })); } function makeVariable(st: State): void { const form = st.form; const f = funcOf(st); if (!form || !f) return; const name = form.name.trim(); const fail = (key: keyof typeof TEXT): void => { form.why = tf(key); drawVars(st); }; if (!name) return fail("NeedName"); const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)]; const mine = f.variables.find((v) => v.name === name); if (taken.includes(name) && !mine) return fail("SameName"); const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) }); if (busy.has(st.cell)) return fail("Busy"); let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); const v: CfVariable = { name, type: form.type, unit: form.unit.trim() || "-", example: null, cell: st.cell, }; if (form.type === "number") { if (example !== null) { const n = Number(String(example).replace(/,/g, "")); if (!Number.isFinite(n)) return fail("NotNumber"); example = n; } const lo = form.min.trim() === "" ? null : Number(form.min); const hi = form.max.trim() === "" ? null : Number(form.max); if (lo !== null && hi !== null) { if (!(lo < hi)) return fail("BadRange"); v.range = [lo, hi]; } } else if (form.type === "choice") { v.choices = form.choices .split(",") .map((x) => x.trim()) .filter(Boolean); } v.example = example; const cell = st.cell; st.form = null; patchFunc(st, (x) => { const sheet = structuredClone(x.sheet); if (example !== null) sheet.칸[cell] = { 값: example }; return { ...x, sheet, inputs: x.inputs.filter((i) => i.cell !== cell), variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v], }; }); } /** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */ function incoming(st: State): { port: string; unit: string }[] { const out = new Map(); for (const l of st.lg.links) { if (l.to.box !== st.boxId || out.has(l.to.name)) continue; const src = st.lg.boxes.find((b) => b.id === l.from.box); const unit = src?.func && src.func.output.name === l.from.name ? src.func.output.unit : (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ?? src?.input?.variables.find((v) => v.name === l.from.name)?.unit ?? src?.unit ?? "-"); out.set(l.to.name, unit || "-"); } // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); return [...out].map(([port, unit]) => ({ port, unit })); } function problems(st: State): string[] { const f = funcOf(st); if (!f) return []; const out: string[] = []; for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) { if (typeof v === "object" && "오류" in v) { out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`); } } for (const p of incoming(st)) { if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); } if (!f.output.cell) out.push(tf("OutNone")); return out; } /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { const f = funcOf(st); if (!f) return; const marks = new Map(); for (const i of f.inputs.filter((x) => x.cell)) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); for (const v of f.variables) if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); if (f.output.cell) marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); const list = problems(st); const bad = new Set(list.map((p) => p.split(" ")[0])); for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { const addr = c.dataset.addr ?? ""; for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a]; const m = marks.get(addr); if (m) { c.dataset.cfKind = m.kind; c.dataset.cfName = m.label; } if (locked.has(addr)) c.dataset.cfLock = "🔒"; if (bad.has(addr)) c.dataset.cfErr = "1"; } st.parts.msg.textContent = list.length ? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}` : ""; st.parts.msg.hidden = !list.length; } /* ------------------------------ 위 줄들 ------------------------------ */ function drawHead(st: State): void { const box = boxOf(st); const unit = el("input", { className: "ui-input m02-dfpanel-unit", attrs: { type: "text", "aria-label": tf("Unit") }, }); unit.value = box?.unit ?? "-"; if (st.readOnly) unit.readOnly = true; unit.addEventListener("change", () => { const v = unit.value.trim() || "-"; unit.value = v; patchBox(st, (b) => ({ ...b, unit: v, func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, })); }); st.parts.head.replaceChildren( el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), el("span", { className: "m02-dfpanel-cell", text: `${tf("Cell")} ${st.cell}` }), el("label", { className: "m02-dfpanel-unitbox", children: [el("span", { className: "m02-dfpanel-note", text: tf("Unit") }), unit], }), ); } function drawGot(st: State): void { const f = funcOf(st); const items = incoming(st); const chips = items.map(({ port, unit }) => { const at = f?.inputs.find((i) => i.port === port)?.cell; const b = el("button", { className: `m02-dfpanel-chip is-in${at ? " is-placed" : ""}`, text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`, attrs: { type: "button", draggable: st.readOnly ? "false" : "true" }, }); b.addEventListener("click", () => placePort(st, port, st.cell)); b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port)); return b; }); st.parts.got.replaceChildren( el("span", { className: "m02-dfpanel-label", text: tf("Got") }), ...(chips.length ? chips : [el("span", { className: "m02-dfpanel-note", text: tf("NoGot") })]), ...(chips.length && !st.readOnly ? [el("span", { className: "m02-dfpanel-note", text: tf("GotHint") })] : []), ); } function formField( st: State, label: keyof typeof TEXT, key: "name" | "unit" | "min" | "max" | "choices" | "example", cls = "", ): HTMLElement { const form = st.form!; const input = el("input", { className: `ui-input m02-dfpanel-field ${cls}`, attrs: { type: "text", "aria-label": tf(label) }, }); input.value = form[key]; input.addEventListener("input", () => (form[key] = input.value)); input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st)); return el("label", { className: "m02-dfpanel-fieldbox", children: [el("span", { className: "m02-dfpanel-note", text: tf(label) }), input], }); } function drawVars(st: State): void { const f = funcOf(st); const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })]; for (const v of f?.variables ?? []) { const chip = el("span", { className: "m02-dfpanel-chip is-var", children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`], }); if (!st.readOnly) { chip.append( createButton({ label: "×", variant: "ghost", size: "sm", onClick: () => patchFunc(st, (x) => ({ ...x, variables: x.variables.filter((o) => o.name !== v.name), })), }), ); chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`); } row.push(chip); } if (!f?.variables.length) row.push(el("span", { className: "m02-dfpanel-note", text: tf("NoVars") })); const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })]; if (!st.readOnly) { const form = st.form; if (!form) { parts.push( el("div", { className: "m02-dfpanel-row", children: [ createButton({ label: tf("MakeVar"), variant: "ghost", size: "sm", onClick: () => { st.form = { name: "", type: "number", unit: "", min: "", max: "", choices: "", example: "", why: "", }; drawVars(st); }, }), ], }), ); } else { const type = el("select", { className: "ui-select m02-dfpanel-field", attrs: { "aria-label": tf("VarType") }, }); for (const [v, k] of [ ["number", "Number"], ["choice", "Choice"], ["text", "Text"], ] as const) { type.append(el("option", { attrs: { value: v }, text: tf(k) })); } type.value = form.type; type.addEventListener("change", () => { form.type = type.value as CfVariable["type"]; drawVars(st); }); parts.push( el("div", { className: "m02-dfpanel-row m02-dfpanel-form", children: [ formField(st, "VarName", "name"), el("label", { className: "m02-dfpanel-fieldbox", children: [el("span", { className: "m02-dfpanel-note", text: tf("VarType") }), type], }), formField(st, "VarUnit", "unit"), ...(form.type === "number" ? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")] : []), ...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []), formField(st, "VarExample", "example"), el("span", { className: "m02-dfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }), createButton({ label: tf("Make"), variant: "filled", size: "sm", onClick: () => makeVariable(st), }), createButton({ label: tf("Cancel"), variant: "ghost", size: "sm", onClick: () => ((st.form = null), drawVars(st)), }), ...(form.why ? [el("span", { className: "m02-dfpanel-why", text: `⚠ ${form.why}` })] : []), ], }), ); } } st.parts.vars.replaceChildren(...parts); } function drawBar(st: State): void { const f = funcOf(st); if (st.readOnly) { st.parts.bar.replaceChildren( el("span", { className: "m02-dfpanel-note", text: tf("ReadOnly") }), ); return; } const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); st.parts.bar.replaceChildren( el("div", { className: "m02-dfpanel-row", children: [ createButton({ label: tf("OutBtn"), variant: "ghost", size: "sm", onClick: () => setOutput(st, st.cell), }), el("span", { className: "m02-dfpanel-chip is-out", text: outNow }), el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), ], }), el("div", { className: "m02-dfpanel-row", children: [ el("span", { className: "m02-dfpanel-label", text: tf("Frames") }), ...FRAMES.map(([key, formula]) => createButton({ label: tf(key), variant: "ghost", size: "sm", onClick: () => putFormula(st, formula), }), ), el("span", { className: "m02-dfpanel-note", text: tf("FrameHint") }), ], }), ); } /** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ function setOutput(st: State, cell: string): void { const f = funcOf(st); if (!f) return; if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) { return void showToast(tf("Busy"), "error"); } const row = cell.replace(/^[A-Z]+/, ""); const label = f.sheet.칸[`A${row}`]; const name = String(label?.값 ?? f.output.name ?? "").trim() || cell; patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } })); } /* ------------------------------ 편집기 ------------------------------ */ /** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ function putFormula(st: State, formula: string): void { const root = st.parts.sheet; const bar = root.querySelector(".ss-formula"); const addr = root.querySelector(".ss-addr"); if (!bar || !addr || st.readOnly) return; const at = st.cell; bar.focus(); bar.value = formula; bar.dispatchEvent(new Event("input", { bubbles: true })); bar.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), ); addr.value = at; addr.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), ); } /** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */ function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] { for (const cell of Object.values(sheet.칸)) delete cell.서식; return sheet; } /** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */ function onBook(st: State, book: Workbook): void { const f = funcOf(st); if (!f) return; const sheet = plain(structuredClone(book.시트[0])); const hit = f.inputs.find( (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), ); if (hit) { showToast(tf("Locked"), "error"); st.json = sheetJson(f); return st.handle?.setDoc(bookOf(f)); } st.json = JSON.stringify(sheet); patchFunc(st, (x) => ({ ...x, sheet })); setTimeout(() => refreshSnap(st), 0); } /** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ function refreshSnap(st: State): void { if (!st.handle) return; st.snap = st.handle.recalc(); st.redraw(); } async function mountSheet(st: State): Promise { const { createSpreadsheet } = await loadSheet(); const f = funcOf(st); if (!st.parts.sheet.isConnected || st.handle || !f) return; st.parts.sheet.replaceChildren(); st.json = sheetJson(f); st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), { preview: true, tabs: false, readOnly: st.readOnly, onChange: (b) => onBook(st, b), onSelect: (s) => { st.cell = s.칸; drawHead(st); if (st.form) drawVars(st); }, }); st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); st.observer.observe(st.parts.sheet, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-addr"], }); st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault()); st.parts.sheet.addEventListener("drop", (e) => { e.preventDefault(); const port = e.dataTransfer?.getData("text/plain"); const cell = (e.target as Element | null)?.closest(".aislo-cell")?.dataset.addr; if (port && cell) placePort(st, port, cell); }); refreshSnap(st); } /** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ export function renderFuncPanel( host: HTMLElement, lg: CfLogic, boxId: string, on: Handlers, opts: { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.boxId === boxId && old.readOnly === readOnly) { old.lg = lg; old.on = on; const f = funcOf(old); if (old.handle && f && sheetJson(f) !== old.json) { old.json = sheetJson(f); old.handle.setDoc(bookOf(f)); setTimeout(() => refreshSnap(old), 0); return; } return old.redraw(); } if (old) { old.observer?.disconnect(); old.handle?.destroy(); } const parts: State["parts"] = { head: el("div", { className: "m02-dfpanel-head" }), got: el("div", { className: "m02-dfpanel-row" }), vars: el("div", { className: "m02-dfpanel-vars" }), bar: el("div", { className: "m02-dfpanel-bar" }), msg: el("p", { className: "m02-dfpanel-msg", attrs: { role: "status" } }), sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }), }; const st: State = { boxId, lg, on, readOnly, handle: null, snap: {}, json: "", cell: "A1", form: null, parts, observer: null, redraw: () => { drawHead(st); drawGot(st); drawVars(st); drawBar(st); applyMarks(st); }, }; states.set(host, st); host.classList.add("m02-dfpanel"); host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); parts.msg.hidden = true; st.redraw(); void mountSheet(st); }