- Entry 시험판 고치는 화면을 옮겨 적음(Entry 는 안 고침) — 다이어그램 · 상세 내용 · 원가 · 검증 · 실행 취소 · onChange 객체 그대로 - 다이어그램 opts.readOnly(끌기 · 잇기 · 더하기 · 지우기 막음 · 고르기 · 흐름 따라가기는 됨) · opts.fitKey(바뀌면 다시 맞춤) - 알약 정보 글 · SVG 도우미를 Parts 로(Diagram 681줄) - DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
342 lines
12 KiB
TypeScript
342 lines
12 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>;
|
|
}
|
|
|
|
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,
|
|
});
|
|
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,
|
|
};
|
|
}
|