/* ============================================================================= * M01_SL_Panel.ts * 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 · * 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { CalcValue, CalcValues, SpreadsheetHandle, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model"; import "./M01_SL_Panel.css"; const TEXT = { Cell: ["고른 칸", "Selected cell"], PillIn: ["입력 알약으로", "Make input pill"], PillOut: ["출력 알약으로", "Make output pill"], AskName: ["알약 이름", "Pill name"], Ok: ["정함", "Set"], Cancel: ["취소", "Cancel"], Frames: ["틀", "Templates"], FrameMore: ["할증", "Markup"], FrameDiv: ["나누기", "Divide"], FrameIf: ["만약", "If"], FrameMin: ["최소", "Min"], FrameMax: ["최대", "Max"], FrameUp: ["올림", "Round up"], FrameLook: ["표에서 찾기", "Look up in table"], FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], In: ["입력", "In"], Out: ["출력", "Out"], Lock: ["선", "Wire"], Pills: ["알약", "Pills"], NoPills: [ "아직 알약이 없음 — 칸을 골라 알약으로 정함", "No pills yet — pick a cell and make it a pill", ], Value: ["값", "Value"], Unit: ["단위", "Unit"], From: ["어디서 오나", "Comes from"], To: ["어디로 가나", "Goes to"], NoFrom: ["아직 이어지지 않음", "Not connected yet"], NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"], Close: ["닫기", "Close"], ReadOnly: ["표준은 읽기만", "Read only"], Loading: ["시트를 불러오는 중", "Loading sheet"], BreakAsk: [ "이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?", "This cell receives a wire. Cut the wire and edit it yourself?", ], BreakOk: ["선을 끊고 고침", "Cut and edit"], Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], Problem: ["⚠ ", "⚠ "], Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"], 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 tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; /** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ const FRAMES: [keyof typeof TEXT, string][] = [ ["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(sl: SheetLogic): void; pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void; }; type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet"); interface State { nodeId: string; sl: SheetLogic; on: Handlers; readOnly: boolean; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; /** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */ json: string; cell: string; info: { dir: "in" | "out"; name: string } | null; asking: "in" | "out" | null; busy: boolean; parts: { cell: HTMLElement; bar: HTMLElement; msg: HTMLElement; sheet: HTMLElement; pills: HTMLElement; info: 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 norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null }); const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId); const shown = (st: State): Workbook => ({ ...st.sl.workbook, 활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성, }); /* ------------------------------ 값 · 말 ------------------------------ */ function valueText(v: CalcValue | number | string | boolean | undefined | null): string { if (v === undefined || v === null) return ""; if (typeof v !== "object") return String(v); if ("수" in v) return v.수; if ("글" in v) return v.글; if ("참" in v) return v.참 ? "TRUE" : "FALSE"; return v.오류; } function valueAt(st: State, addr: string): string { const node = nodeOf(st); const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId); const cell = sheet?.칸[addr]; if (!cell) return ""; if (cell.식 === undefined) return valueText(cell.값); return valueText(st.snap[sheet!.id]?.[addr]); } const edgesIn = (st: State, pill: string): SlEdge[] => st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill); const edgesOut = (st: State, pill: string): SlEdge[] => st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill); /** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */ function lockedCells(st: State): Map { const out = new Map(); for (const p of nodeOf(st)?.pillsIn ?? []) { const e = edgesIn(st, p.name)[0]; if (e) out.set(p.cell, e); } return out; } /** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */ function problems(st: State): Map { const out = new Map(); const node = nodeOf(st); if (!node || !st.handle) return out; const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId); const snap = st.snap[node.sheetId] ?? {}; for (const [addr, v] of Object.entries(snap)) { if (typeof v === "object" && "오류" in v) { out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`); } } for (const p of node.pillsIn) { const filled = sheet?.칸[p.cell]?.식 !== undefined; if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") { out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`); } } return out; } /** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ function refreshSnap(st: State): void { if (!st.handle) return; st.snap = st.handle.recalc(); st.redraw(); } /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { const node = nodeOf(st); const grid = st.parts.sheet; if (!node) return; const pill = new Map(); for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p }); for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p }); const lock = lockedCells(st); const bad = problems(st); for (const c of grid.querySelectorAll(".aislo-cell")) { const addr = c.dataset.addr ?? ""; const hit = pill.get(addr); for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a]; if (hit) { c.dataset.slPill = hit.dir; c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`; } if (lock.has(addr)) c.dataset.slLock = tp("Lock"); if (bad.has(addr)) c.dataset.slErr = "1"; } const first = [...bad.values()][0]; st.parts.msg.textContent = first ? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}` : ""; st.parts.msg.hidden = !first; } /* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */ function nameDefault(st: State): string { const row = st.cell.replace(/^[A-Z]+/, ""); const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId); const a = sheet?.칸[`A${row}`]; return a ? valueText(a.값 ?? a.식) : ""; } function drawBar(st: State): void { const { bar, cell } = st.parts; cell.textContent = `${tp("Cell")} ${st.cell}`; const frag: (HTMLElement | string)[] = []; if (st.readOnly) { bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") })); return; } const ask = (dir: "in" | "out"): void => { st.asking = dir; drawBar(st); }; const pillRow = el("div", { className: "m01-slpanel-row", children: [ createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }), createButton({ label: tp("PillOut"), variant: "ghost", size: "sm", onClick: () => ask("out"), }), ], }); frag.push(pillRow); if (st.asking) { const dir = st.asking; const input = el("input", { className: "ui-input m01-slpanel-name", attrs: { type: "text", "aria-label": tp("AskName") }, }); input.value = nameDefault(st); const done = (): void => { const name = input.value.trim(); if (!name) return; st.asking = null; st.on.pill(st.nodeId, st.cell, dir, name); drawBar(st); }; input.addEventListener("keydown", (e) => e.key === "Enter" && done()); frag.push( el("div", { className: "m01-slpanel-row", children: [ el("span", { className: `m01-slpanel-dir is-${dir}`, text: tp(dir === "in" ? "In" : "Out"), }), input, createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }), createButton({ label: tp("Cancel"), variant: "ghost", size: "sm", onClick: () => ((st.asking = null), drawBar(st)), }), ], }), ); queueMicrotask(() => input.focus()); } frag.push( el("div", { className: "m01-slpanel-row", children: [ el("span", { className: "m01-slpanel-note", text: tp("Frames") }), ...FRAMES.map(([key, formula]) => createButton({ label: tp(key), variant: "ghost", size: "sm", onClick: () => putFormula(st, formula), }), ), el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }), ], }), ); bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string")); } function drawPills(st: State): void { const node = nodeOf(st); const chips: HTMLElement[] = []; const add = (dir: "in" | "out", p: SlPill): void => { const b = el("button", { className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`, text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`, attrs: { type: "button" }, }); b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st))); chips.push(b); }; for (const p of node?.pillsIn ?? []) add("in", p); for (const p of node?.pillsOut ?? []) add("out", p); st.parts.pills.replaceChildren( ...(chips.length ? chips : [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]), ); drawInfo(st); } function drawInfo(st: State): void { const box = st.parts.info; const node = nodeOf(st); const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find( (x) => x.name === st.info?.name, ); if (!st.info || !p || !node) { box.hidden = true; box.replaceChildren(); return; } const dir = st.info.dir; const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? ""; const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => { const end = dir === "in" ? e.from : e.to; return `${nameOf(end.node)} · ${end.pill}`; }); const rows: [string, string][] = [ [tp("Value"), valueAt(st, p.cell)], [tp("Unit"), p.unit ?? ""], [ tp(dir === "in" ? "From" : "To"), links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"), ], ]; box.hidden = false; box.replaceChildren( el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }), ...rows.map(([k, v]) => el("div", { className: "m01-slpanel-infoline", children: [el("span", { className: "m01-slpanel-note", text: k }), v], }), ), createButton({ label: tp("Close"), variant: "ghost", size: "sm", onClick: () => ((st.info = null), drawPills(st)), }), ); } /* ------------------------------ 편집기 ------------------------------ */ /** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ 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 }), ); } /** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */ async function onBook(st: State, book: Workbook): Promise { if (st.busy) return; const prev = st.sl; const node = nodeOf(st); const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } }; const before = prev.workbook.시트.find((s) => s.id === node?.sheetId); const after = book.시트.find((s) => s.id === node?.sheetId); const hit = [...lockedCells(st)].find( ([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]), ); if (!hit) { st.json = norm(book); st.sl = next; st.on.change(next); return void setTimeout(() => refreshSnap(st), 0); } st.busy = true; const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk")); st.busy = false; if (!ok) { st.json = norm(shown(st)); return st.handle?.setDoc(structuredClone(shown(st))); } try { const cut = disconnect(next, hit[1].id); const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId); const mine = after?.칸[hit[0]]; if (sheet) { if (mine) sheet.칸[hit[0]] = mine; else delete sheet.칸[hit[0]]; } st.json = norm(cut.workbook); st.sl = cut; st.on.change(cut); setTimeout(() => refreshSnap(st), 0); } catch { showToast(tp("Cannot"), "error"); st.handle?.setDoc(structuredClone(shown(st))); } } async function mountSheet(st: State): Promise { const { createSpreadsheet } = await loadSheet(); if (!st.parts.sheet.isConnected || st.handle) return; st.parts.sheet.replaceChildren(); const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌 st.json = norm(book); st.handle = createSpreadsheet(st.parts.sheet, book, { preview: true, tabs: false, readOnly: st.readOnly, onChange: (b) => void onBook(st, b), onSelect: (s) => { st.cell = s.칸; st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`; if (st.asking) drawBar(st); }, }); st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); st.observer.observe(st.parts.sheet, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-addr"], }); refreshSnap(st); } /** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ export function renderPanel( host: HTMLElement, sl: SheetLogic, nodeId: string, on: Handlers, opts: { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.nodeId === nodeId && old.readOnly === readOnly) { old.sl = sl; old.on = on; if (old.handle && norm(shown(old)) !== old.json) { old.json = norm(shown(old)); old.handle.setDoc(structuredClone(shown(old))); setTimeout(() => refreshSnap(old), 0); return; } return old.redraw(); } if (old) { old.observer?.disconnect(); old.handle?.destroy(); } const parts: State["parts"] = { cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }), bar: el("div", { className: "m01-slpanel-bar" }), msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }), sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }), pills: el("div", { className: "m01-slpanel-pills" }), info: el("div", { className: "m01-slpanel-info" }), }; const st: State = { nodeId, sl, on, readOnly, handle: null, snap: {}, json: "", cell: "A1", info: null, asking: null, busy: false, parts, observer: null, redraw: () => { drawBar(st); drawPills(st); applyMarks(st); }, }; states.set(host, st); const node = nodeOf(st); host.classList.add("m01-slpanel"); host.replaceChildren( el("div", { className: "m01-slpanel-head", children: [el("strong", { text: node?.name ?? "" }), parts.cell], }), parts.bar, parts.msg, parts.sheet, parts.pills, parts.info, ); parts.msg.hidden = true; st.redraw(); void mountSheet(st); }