Files
Aislo/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts
T

698 lines
24 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_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<string, keyof typeof TEXT> = {
"#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<HTMLElement, State>();
let sheetModule: Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> | null = null;
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> =>
(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<string, string> => {
const m = new Map<string, string>();
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<string, string>();
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<string, { kind: string; label: string }>();
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<HTMLElement>(".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<HTMLTextAreaElement>(".ss-formula");
const addr = root.querySelector<HTMLInputElement>(".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<void> {
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<HTMLElement>(".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);
}