Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts
T
eomsangdonandClaude Opus 5.5 deabd978c0 fix(M02): 흐름도 패널 처음 배율 = 읽히는 최소 0.6(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체(0.25) · 크게 보기는 그대로 0.9(PLAN 36-3 덧)
- 다이어그램 opts.fitAllMin(단추) · fitMin(저절로) 나눔 · Paper · Panel 이 넘김
- 시험 diagram_parts 8 + panel 2 통과 · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:39:21 +09:00

348 lines
13 KiB
TypeScript

/* =============================================================================
* 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<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
/** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */
fitAllMin?(): 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<CfEvalOptions["logics"]> = {};
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?.(),
fitAllMin: opts.fitAllMin?.(),
});
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,
};
}