Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts
T

693 lines
23 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_DF_FuncPanel.ts
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type {
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
import {
conditionsOf,
drawCells,
drawFormula,
variableFrom,
type ExtraContext,
} from "./M02_DF_FuncExtra";
import { createRoundRow } from "./M02_DF_RoundRow";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
import "./M02_DF_FuncPanel.css";
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;
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
detail: DetailOptions;
handle: SpreadsheetHandle | null;
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
snap: CalcValues;
/** 선 규칙을 지나 이 상자 받는 알약에 들어온 값 */
arrived: Record<string, unknown>;
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
json: string;
cell: string;
form: VarForm | null;
/** 단위 표준이 제안한 출력 소수자리 */
suggest: number | null;
parts: {
head: HTMLElement;
formula: HTMLElement;
cells: 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 = (sheet: Sheet): Workbook => ({
종류: "통합문서",
판: 1,
열: "cf",
서식: [{}],
시트: [structuredClone(sheet)],
활성: sheet.id,
});
/** 보이는 시트 — 받은 알약 칸에는 선 규칙을 지나 들어온 값(arrived)을 넣어 보임(저장하는 시트에는 안 넣음) */
function withArrived(f: CfFunc, base: Sheet, arrived: Record<string, unknown>): Sheet {
const sheet = structuredClone(base);
for (const i of f.inputs) {
const v = arrived[i.port];
if (i.cell && (typeof v === "number" || typeof v === "string" || typeof v === "boolean"))
sheet.칸[i.cell.toUpperCase()] = { 값: v };
}
return sheet;
}
const shownOf = (st: State): Sheet => withArrived(funcOf(st)!, funcOf(st)!.sheet, st.arrived);
/* ------------------------------ 바꾸기 ------------------------------ */
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 made = variableFrom(form, f, st.cell, usedCells);
if ("why" in made) {
form.why = tf(made.why);
return drawVars(st);
}
const { v, cell } = made;
st.form = null;
patchFunc(st, (x) => {
const sheet = structuredClone(x.sheet);
if (v.example !== null) sheet.칸[cell] = { 값: v.example };
return {
...x,
sheet,
inputs: x.inputs.filter((i) => i.cell !== cell),
variables: [...x.variables.filter((o) => o.name !== v.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 || "-");
}
// 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리)
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;
}
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
const extra = (st: State): ExtraContext => ({
lg: st.lg,
boxId: st.boxId,
detail: st.detail,
arrived: st.arrived,
readOnly: st.readOnly,
place: (port, cell) => placePort(st, port, cell),
setOutput: (cell) => setOutput(st, cell),
patch: (make) => patchFunc(st, make),
used: (f) => usedCells(f),
});
/* ------------------------------ 칸 위 표시 ------------------------------ */
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.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<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 = createUnitPick({
value: box?.unit ?? "-",
readOnly: st.readOnly,
extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
ariaLabel: tf("Unit"),
onPick: (v) => {
patchBox(st, (x) => ({
...x,
unit: v,
func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
}));
void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
},
});
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!;
if (key === "unit") {
return el("label", {
className: "m02-dfpanel-fieldbox",
children: [
el("span", { className: "m02-dfpanel-note", text: tf(label) }),
createUnitPick({
value: form.unit || "-",
ariaLabel: tf(label),
onPick: (u) => ((form.unit = u), drawVars(st)),
}),
],
});
}
const input = el("input", {
className: `ui-input ui-input--sm 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 ui-select--sm 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") }),
],
}),
...(f?.output.cell
? [
createRoundRow({
round: f.output.round,
suggest: st.suggest,
readOnly: st.readOnly,
onApply: (round) => writeRound(st, round),
}),
]
: []),
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") }),
],
}),
);
}
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
function writeRound(st: State, round: CfRound | null): void {
patchFunc(st, (x) => {
const { round: _old, ...rest } = x.output;
return { ...x, output: round ? { ...rest, round } : rest };
});
}
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
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 shown = shownOf(st);
const hit = f.inputs.find(
(i) =>
i.cell &&
JSON.stringify(shown.칸[i.cell.toUpperCase()]) !==
JSON.stringify(sheet.칸[i.cell.toUpperCase()]),
);
if (hit) {
showToast(tf("Locked"), "error");
st.json = JSON.stringify(shown);
return st.handle?.setDoc(bookOf(shown));
}
// 보이려고 넣은 들어온 값은 저장하는 시트에 남기지 않음
for (const i of f.inputs) {
const key = i.cell.toUpperCase();
if (!i.cell) continue;
if (f.sheet.칸[key]) sheet.칸[key] = f.sheet.칸[key];
else delete sheet.칸[key];
}
st.json = JSON.stringify(withArrived(f, sheet, st.arrived));
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();
const shown = shownOf(st);
st.json = JSON.stringify(shown);
st.handle = createSpreadsheet(st.parts.sheet, bookOf(shown), {
preview: true,
tabs: false,
readOnly: st.readOnly,
conditions: conditionsOf(() => extra(st)),
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);
}
/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */
const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record<string, unknown> =>
evalSafe(lg, o)?.arrived?.[boxId] ?? {};
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
export function renderFuncPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: Handlers,
opts: DetailOptions & { 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;
old.detail = opts;
old.arrived = arrivedOf(lg, boxId, opts);
const f = funcOf(old);
if (old.handle && f && JSON.stringify(shownOf(old)) !== old.json) {
old.json = JSON.stringify(shownOf(old));
old.handle.setDoc(bookOf(shownOf(old)));
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" }),
formula: el("div"),
cells: el("div"),
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,
detail: opts,
arrived: arrivedOf(lg, boxId, opts),
handle: null,
snap: {},
json: "",
cell: "A1",
form: null,
suggest: null,
parts,
observer: null,
redraw: () => {
drawHead(st);
drawGot(st);
drawVars(st);
drawBar(st);
drawFormula(st.parts.formula, extra(st));
drawCells(st.parts.cells, extra(st));
applyMarks(st);
},
};
states.set(host, st);
host.classList.add("m02-dfpanel");
host.replaceChildren(
parts.head,
parts.formula,
parts.got,
parts.vars,
parts.bar,
parts.msg,
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
);
parts.msg.hidden = true;
st.redraw();
void mountSheet(st);
}