/* ============================================================================= * M02_DF_Paper.ts * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, el } from "@ui/ui_template_elements"; import * as api from "./M02_DF_Api"; import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram"; import * as ev from "./M02_DF_Eval"; import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; import { renderFuncPanel } from "./M02_DF_FuncPanel"; import { renderInputPanel } from "./M02_DF_InputPanel"; import { renderLinkPanel } from "./M02_DF_LinkPanel"; import * as model from "./M02_DF_Model"; import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; import { renderOutputPanel } from "./M02_DF_OutputPanel"; import { renderPillPanel } from "./M02_DF_PillPanel"; import { renderSummary } from "./M02_DF_Summary"; import type { CfSel } from "./M02_DF_Verify"; import { detailFrame, drawHead, drawWarnings, fetchValues, loadLogics, queriesOf, type DfPick, } from "./M02_DF_Entry_Detail"; import "./M02_DF_Entry.css"; const TEXT = { Undo: ["실행 취소", "Undo"], Color: ["상자 색", "Box colour"], BoxName: ["상자 이름", "Box name"], NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"], } as const; const tp = (key: keyof typeof TEXT, v = ""): string => (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v); export interface PaperOpts { logic: CfLogic; readOnly?: boolean; /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ onChange(lg: CfLogic): void; /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ logics?(key: string): Promise; /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ fitMin?(): number; } export interface Paper { /** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */ parts: { diagram: HTMLElement; detail: HTMLElement; summary: HTMLElement; note: HTMLElement; undo: HTMLButtonElement; }; get(): CfLogic; /** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */ set(lg: CfLogic): void; undo(): void; /** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */ refit(): void; redraw(): void; } export function createPaper(opts: PaperOpts): Paper { const readOnly = !!opts.readOnly; let lg = opts.logic; let stack: CfLogic[] = []; let pick: DfPick = null; let values: CfValues = {}; let valueSig = ""; let logics: NonNullable = {}; let logicSig = ""; let logicNotes: string[] = []; let notes: string[] = []; let broken: CfBreak[] = []; let result: CfEvalResult; let fitKey = 0; const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); const diagramHost = el("div", { className: "m02-dfp__diagram" }); const summaryHost = el("div", { className: "m02-dfp__summary" }); const colorBox = el("input", { className: "m02-df__color-input", attrs: { type: "color", "aria-label": tp("Color") }, }); colorBox.addEventListener("change", () => { const id = pickedBox(); if (id) edit(model.setColor(lg, id, colorBox.value)); }); const colorRow = el("label", { className: "m02-df__color", children: [el("span", { text: tp("Color") }), colorBox], }); const boxName = createInputField({ label: tp("BoxName"), compact: true }); boxName.input.addEventListener("change", () => { const box = lg.boxes.find((b) => b.id === pickedBox()); if (!box) return; const next = boxName.input.value.trim(); const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); if (taken) note.textContent = tp("NameTaken", next); if (!next || taken || next === box.name) return void (boxName.input.value = box.name); edit(model.updateBox(lg, box.id, { name: next })); }); // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만 const funcHost = el("div"); const outHost = el("div"); const inHost = el("div"); const linkHost = el("div"); const pillHost = el("div"); const detail = detailFrame(); detail.settings.append(boxName.root, colorRow); // 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨) const lock = el("fieldset", { className: "m02-dfp__lock", children: [inHost, funcHost, outHost, linkHost, pillHost], }); lock.disabled = readOnly; detail.body.append(lock); if (readOnly) detail.root.prepend(el("p", { className: "m02-df__muted", text: tp("ReadOnly") })); const evalNow = (): CfEvalResult => { try { return ev.evalLogic(lg, values, { logics }); } catch (e) { const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] }; } }; /** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */ const edit = (next: CfLogic): void => { if (readOnly || next === lg) return; stack = [...stack, lg]; lg = next; opts.onChange(lg); redraw(); }; /** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */ const editExample = (next: CfLogic): void => { if (!readOnly) return edit(next); lg = next; redraw(); }; const editFromDiagram = (next: CfLogic): void => { edit(next); setTimeout(redraw); }; const refreshValues = (): void => { const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q)); if (sig === valueSig) return; valueSig = sig; void fetchValues(api.cfValues, lg, pill, model.elementPort).then( (got) => { if (sig !== valueSig) return; values = got.values; notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : []; redraw(); }, (e: unknown) => { notes = [tp("Values", api.cfPlain(e))]; redraw(); }, ); }; const refreshLogics = (): void => { const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); if (sig === logicSig) return; logicSig = sig; const get = opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : []; redraw(); }); }; const choose = (sel: CfSel): void => { const box = lg.boxes.find((b) => b.id === sel.box); pick = sel.link ? { kind: "link", id: sel.link } : box && sel.port ? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) } : box ? { kind: "box", id: box.id } : null; redraw(); }; /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { try { if (pick?.kind === "pill") { const p = { box: pick.box, name: pick.port }; return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) }; } const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id); if (link) return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) }; } catch { /* 따라갈 수 없으면 칠하지 않음 */ } return null; }; const diagramOn = { select: (id: string | null, pill?: DfPillSel): void => { pick = pill ? { kind: "pill", ...pill } : !id ? null : lg.links.some((l) => l.id === id) ? { kind: "link", id } : { kind: "box", id }; setTimeout(redraw); }, move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)), link: (from: CfPort, to: CfPort): string | null => { const got = model.link(lg, from, to); if (got.why) return got.why; editFromDiagram(got.lg); return null; }, unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)), add: (kind: CfKind, x: number, y: number): void => { const got = model.addBox(lg, kind, x, y); pick = { kind: "box", id: got.box.id }; editFromDiagram(got.lg); }, remove: (id: string): void => { if (pickedBox() === id) pick = null; editFromDiagram(model.removeBox(lg, id)); }, }; const drawPanel = (): void => { const boxId = pickedBox(); if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; const box = lg.boxes.find((b) => b.id === pickedBox()); drawHead(detail, lg, pick); (detail.settings.parentElement as HTMLElement).hidden = !box || readOnly; colorRow.hidden = !box; boxName.root.hidden = !box || box.kind === "output"; if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; linkHost.hidden = pick?.kind !== "link"; pillHost.hidden = pick?.kind !== "pill"; if (pick?.kind === "link") renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); if (pick?.kind === "pill") renderPillPanel( pillHost, lg, pick.box, pick.port, { pick: (id) => choose({ box: id }) }, { values, logics }, ); drawWarnings(detail, pick, broken); if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit }); if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit }); if (box?.kind === "output") renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); }; const redraw = (): void => { result = evalNow(); refreshValues(); refreshLogics(); const linkId = pick?.kind === "link" ? pick.id : ""; if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; try { broken = ev.breaks(lg, values, { logics }); } catch { broken = result.breaks ?? []; } undo.disabled = readOnly || !stack.length; renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, selectedPill: pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, trace: traceNow(), breaks: broken, readOnly, fitKey, fitMin: opts.fitMin?.(), }); drawPanel(); renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); }; const doUndo = (): void => { const back = stack[stack.length - 1]; if (!back || readOnly) return; stack = stack.slice(0, -1); lg = back; opts.onChange(lg); redraw(); }; const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); redraw(); return { parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo }, get: () => lg, set: (next) => { lg = next; stack = []; pick = null; fitKey += 1; redraw(); }, undo: doUndo, refit: () => { fitKey += 1; redraw(); }, redraw, }; }