From 47eba22e54e5711a0baa7798d06f645edb48c5a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:05:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02-CostFlow):=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=ED=8C=90=20FuncPanel=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20=EC=8B=9C=ED=8A=B8=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EC=9D=80=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=B9=B8=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=ED=8B=80=20=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../CostFlow/M02_CF_FuncPanel.css | 180 +++++ .../CostFlow/M02_CF_FuncPanel.ts | 697 ++++++++++++++++++ 2 files changed, 877 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css new file mode 100644 index 000000000..3203eff96 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css @@ -0,0 +1,180 @@ +.m01-cfpanel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-cfpanel-head, +.m01-cfpanel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-cfpanel-head { + justify-content: space-between; +} + +.m01-cfpanel-title { + font-size: var(--text-subheading); +} + +.m01-cfpanel-unitbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-left: auto; +} + +.m01-cfpanel-unit { + width: 6em; + text-align: center; +} + +.m01-cfpanel-cell { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-cfpanel-label { + font-weight: var(--font-weight-bold); +} + +.m01-cfpanel-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-cfpanel .ui-btn, +.m01-cfpanel .ui-input, +.m01-cfpanel .ui-select { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-cfpanel-form { + align-items: flex-end; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-cfpanel-fieldbox { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.m01-cfpanel-field { + width: 9em; +} + +.m01-cfpanel-field.is-num { + width: 6em; +} + +.m01-cfpanel-why, +.m01-cfpanel-msg { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-cfpanel-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-height: 44px; + padding: 0 var(--spacing-12); + border: 2px solid; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); +} + +button.m01-cfpanel-chip { + cursor: pointer; +} + +button.m01-cfpanel-chip:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-cfpanel-chip.is-in { + border-color: #3b82f6; +} + +.m01-cfpanel-chip.is-in:not(.is-placed) { + border-style: dashed; +} + +.m01-cfpanel-chip.is-out { + border-color: #f59e0b; +} + +.m01-cfpanel-chip.is-var { + border-color: #22a06b; +} + +/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ +.m01-cfpanel-sheet { + position: relative; + height: max(640px, calc(100vh - 260px)); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */ +.m01-cfpanel .aislo-cell[data-cf-kind="in"] { + box-shadow: inset 0 0 0 2px #3b82f6; +} + +.m01-cfpanel .aislo-cell[data-cf-kind="var"] { + box-shadow: inset 0 0 0 2px #22a06b; +} + +.m01-cfpanel .aislo-cell[data-cf-kind="out"] { + box-shadow: inset 0 0 0 2px #f59e0b; +} + +.m01-cfpanel .aislo-cell[data-cf-kind]::after { + content: attr(data-cf-name); + position: absolute; + top: 0; + right: 0; + padding: 0 4px; + border-radius: 0 0 0 6px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-cfpanel .aislo-cell[data-cf-lock]::before { + content: attr(data-cf-lock); + position: absolute; + bottom: 0; + left: 0; + padding: 0 4px; + background: var(--color-surface); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-cfpanel .aislo-cell[data-cf-err] { + outline: 2px dashed var(--color-danger); + outline-offset: -2px; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts new file mode 100644 index 000000000..f43861d16 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts @@ -0,0 +1,697 @@ +/* ============================================================================= + * M02_CF_FuncPanel.ts + * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · + * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_CF_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_CF_Model"; +import "./M02_CF_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"], + 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 || "-"); + } + 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)) 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) 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.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 m01-cfpanel-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: "m01-cfpanel-title", text: box?.name ?? "" }), + el("span", { className: "m01-cfpanel-cell", text: `${tf("Cell")} ${st.cell}` }), + el("label", { + className: "m01-cfpanel-unitbox", + children: [el("span", { className: "m01-cfpanel-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: `m01-cfpanel-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: "m01-cfpanel-label", text: tf("Got") }), + ...(chips.length ? chips : [el("span", { className: "m01-cfpanel-note", text: tf("NoGot") })]), + ...(chips.length && !st.readOnly + ? [el("span", { className: "m01-cfpanel-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 m01-cfpanel-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: "m01-cfpanel-fieldbox", + children: [el("span", { className: "m01-cfpanel-note", text: tf(label) }), input], + }); +} + +function drawVars(st: State): void { + const f = funcOf(st); + const row: HTMLElement[] = [el("span", { className: "m01-cfpanel-label", text: tf("Vars") })]; + for (const v of f?.variables ?? []) { + const chip = el("span", { + className: "m01-cfpanel-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: "m01-cfpanel-note", text: tf("NoVars") })); + const parts: HTMLElement[] = [el("div", { className: "m01-cfpanel-row", children: row })]; + if (!st.readOnly) { + const form = st.form; + if (!form) { + parts.push( + el("div", { + className: "m01-cfpanel-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 m01-cfpanel-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: "m01-cfpanel-row m01-cfpanel-form", + children: [ + formField(st, "VarName", "name"), + el("label", { + className: "m01-cfpanel-fieldbox", + children: [el("span", { className: "m01-cfpanel-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: "m01-cfpanel-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: "m01-cfpanel-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: "m01-cfpanel-note", text: tf("ReadOnly") }), + ); + return; + } + const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); + st.parts.bar.replaceChildren( + el("div", { + className: "m01-cfpanel-row", + children: [ + createButton({ + label: tf("OutBtn"), + variant: "ghost", + size: "sm", + onClick: () => setOutput(st, st.cell), + }), + el("span", { className: "m01-cfpanel-chip is-out", text: outNow }), + el("span", { className: "m01-cfpanel-note", text: tf("OutOnly") }), + ], + }), + el("div", { + className: "m01-cfpanel-row", + children: [ + el("span", { className: "m01-cfpanel-label", text: tf("Frames") }), + ...FRAMES.map(([key, formula]) => + createButton({ + label: tf(key), + variant: "ghost", + size: "sm", + onClick: () => putFormula(st, formula), + }), + ), + el("span", { className: "m01-cfpanel-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) => 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: "m01-cfpanel-head" }), + got: el("div", { className: "m01-cfpanel-row" }), + vars: el("div", { className: "m01-cfpanel-vars" }), + bar: el("div", { className: "m01-cfpanel-bar" }), + msg: el("p", { className: "m01-cfpanel-msg", attrs: { role: "status" } }), + sheet: el("div", { className: "m01-cfpanel-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("m01-cfpanel"); + host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + parts.msg.hidden = true; + st.redraw(); + void mountSheet(st); +}