Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts
T

666 lines
24 KiB
TypeScript

/* =============================================================================
* M02_DF_Entry.ts
* 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게.
* 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 —
* M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 ·
* 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { CfListItem } from "./M02_DF_Api";
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import type { CfSel } from "./M02_DF_Verify";
import type { DfPillSel } from "./M02_DF_Diagram_Parts";
import {
detailFrame,
drawHead,
drawWarnings,
fetchValues,
loadLogics,
queriesOf,
unitChoices,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
const loadMods = () =>
Promise.all([
import("./M02_DF_Model"),
import("./M02_DF_Eval"),
import("./M02_DF_Api"),
import("./M02_DF_Diagram"),
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_PillPanel"),
import("./M02_DF_LinkPanel"),
import("./M02_DF_Standards"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
model,
ev,
api,
diagram,
func,
output,
summary,
sample,
input,
pill,
line,
std,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
const TEXT = {
Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"],
Title: ["단가산출 흐름도", "Cost flow"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Diagram: ["다이어그램", "Diagram"],
Summary: ["원가", "Cost"],
Color: ["상자 색", "Box colour"],
BoxName: ["상자 이름", "Box name"],
NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"],
Standards: ["표준 표", "Standards"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
"There are issues — {v} · Save anyway?",
],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
Differs: [
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
"Recalculated on save: {s} — differs from the screen value {c}",
],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Deleted: ["지웠음", "Deleted"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
}
function openDataFlow(): void {
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
let units: string[] | null = null;
const mods = loadMods();
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const body = el("main", { className: "m02-df__body" });
const strip = el("div", { className: "m02-df__strip" });
const close = createButton({
label: te("Close"),
variant: "ghost",
size: "sm",
onClick: () =>
void (async () => {
if (await mayLeave()) paper ? void showStart() : shut();
})(),
});
const page = el("div", {
className: "m02-df",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
children: [
el("header", {
className: "m02-df__head",
children: [
el("h2", { className: "m02-df__title", text: te("Title") }),
el("span", { className: "m02-df__trial", text: te("Trial") }),
strip,
close,
],
}),
body,
],
});
const prevOverflow = document.body.style.overflow;
const shut = (): void => {
page.remove();
document.body.style.overflow = prevOverflow;
};
page.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented) return;
if (ev.key === "Escape") {
void (async () => {
if (await mayLeave()) shut();
})();
return;
}
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
});
const run = (job: (M: Mods) => Promise<void>): void => {
body.replaceChildren(muted(te("Loading")));
void mods.then(job).catch((e: unknown) => {
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
void showStart();
});
};
/* ---------- 시작 화면 ---------- */
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
const row = el("button", {
className: "m02-df__item",
attrs: { type: "button", title },
children: [
el("span", { className: "m02-df__item-name", text: name }),
el("span", { className: "m02-df__item-unit", text: unit }),
],
});
row.addEventListener("click", onClick);
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-df__list" });
body.replaceChildren(
el("div", {
className: "m02-df__start",
children: [
el("section", {
className: "m02-df__start-col",
children: [
bigButton(te("Start_New"), () =>
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
),
el("h3", { text: te("Start_Mine") }),
mineHost,
],
}),
el("section", {
className: "m02-df__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
const M = await mods;
sampleHost.replaceChildren(
...M.sample.sampleList().map((s) =>
listRow(
s.name,
s.resultUnit,
() =>
run(async (M2) => {
const lg = M2.sample.sampleLoad(s.id);
if (lg) await showPaper(M2, lg, false);
}),
s.note,
),
),
);
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
...(mine.length
? mine.map((x) =>
listRow(
x.name,
x.resultUnit,
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
x.saved,
),
)
: [muted(te("Start_MineNone"))]),
);
} catch (e) {
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
}
};
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
/* ---------- 고치는 화면 ---------- */
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
const choices = await units_();
let lg = first;
let savedLg: CfLogic | null = saved ? first : null;
let stack: CfLogic[] = [];
let pick: DfPick = null;
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
let values: CfValues = {};
let valueSig = "";
let logics: NonNullable<CfEvalOptions["logics"]> = {};
let logicSig = "";
let logicNotes: string[] = [];
let broken: CfBreak[] = [];
let notes: string[] = [];
let result: CfEvalResult;
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
const diagramHost = el("div");
// 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나
const colorBox = el("input", {
className: "m02-df__color-input",
attrs: { type: "color", "aria-label": te("Color") },
});
colorBox.addEventListener("change", () => {
const id = pickedBox();
if (id) edit(M.model.setColor(lg, id, colorBox.value));
});
const colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: te("Color") }), colorBox],
});
// 상자 이름 — 입력 · 함수 상자(출력 상자는 출력 판의 이름 칸) · 빈 이름 · 같은 이름은 안 받음
const boxName = createInputField({ label: te("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 = te("NameTaken", { v: next });
if (!next || taken || next === box.name) return void (boxName.input.value = box.name);
edit(M.model.updateBox(lg, box.id, { name: next }));
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(boxName.root, colorRow);
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
}
};
const edit = (next: CfLogic): void => {
if (next === lg) return;
stack = [...stack, lg];
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, M.model.elementPort).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
redraw();
},
(e: unknown) => {
notes = [te("Values", { v: M.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 = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
logicNotes = got.errors.length ? [te("Logics", { v: 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: M.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: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) };
}
const id = pick?.kind === "link" ? pick.id : "";
const link = lg.links.find((l) => l.id === id);
if (link)
return {
back: M.model.traceBack(lg, link.from),
forward: M.model.traceForward(lg, link.to),
};
} catch {
/* 따라갈 수 없으면 칠하지 않음 */
}
return null;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: lg.name,
compact: true,
onInput: (v) => edit({ ...lg, name: v }),
});
name.root.classList.add("m02-df__name");
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
const unit = createSelectField({
label: te("Unit"),
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: lg.resultUnit,
compact: true,
onChange: (v) => edit({ ...lg, resultUnit: v }),
});
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(M.model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = M.model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = M.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(M.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;
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")
M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
M.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") M.input.renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit });
if (box?.kind === "output")
M.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 = M.ev.breaks(lg, values, { logics });
} catch {
broken = result.breaks ?? [];
}
unsaved.hidden = lg === savedLg;
undo.disabled = !stack.length;
deleteButton.hidden = !lg.key;
M.diagram.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,
});
drawPanel();
M.summary.renderSummary(
summaryHost,
lg,
result,
{ change: edit, select: choose },
{ values },
);
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
if (busy) return;
if (!lg.name.trim()) {
note.textContent = te("NoName");
return void name.input.focus();
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values, { logics });
} catch {
problems = [];
}
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
busy = true;
note.textContent = te("Saving");
const screen = result.cost.계;
try {
const got = await M.api.cfSave(lg);
lg = got.logic;
savedLg = lg;
stack = [];
redraw();
const server = got.values.cost.계;
if (Math.abs(server - screen) > 0.5)
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
note.textContent = [...notes, ...result.errors].join("\n");
showToast(te("Saved"), "success");
} catch (e) {
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
} finally {
busy = false;
}
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back) return;
stack = stack.slice(0, -1);
lg = back;
name.input.value = lg.name;
unit.select.value = lg.resultUnit;
redraw();
};
const doDelete = async (): Promise<void> => {
if (!lg.key || busy) return;
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
try {
await M.api.cfDelete(lg.key);
savedLg = lg;
showToast(te("Deleted"), "success");
void showStart();
} catch (e) {
note.textContent = M.api.cfPlain(e);
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
size: "sm",
onClick: () => void doDelete(),
});
strip.replaceChildren(
name.root,
unit.root,
unsaved,
el("div", {
className: "m02-df__actions",
children: [
createButton({
label: te("Standards"),
variant: "ghost",
size: "sm",
onClick: () =>
openModal({
title: te("Standards"),
closeLabel: te("Close"),
mount: (host: HTMLElement) =>
M.std.renderStandards(host, { change: () => redraw() }, { title: false }),
}),
}),
undo,
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
deleteButton,
],
}),
);
body.replaceChildren(
note,
el("div", {
className: "m02-df__layout",
children: [
part(te("Diagram"), "m02-df__diagram", [diagramHost]),
el("div", {
className: "m02-df__lower",
children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])],
}),
],
}),
);
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
redraw();
if (!lg.name) name.input.focus();
};
void showStart();
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
document.body.append(page);
page.tabIndex = -1;
page.focus();
}
/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */
function part(title: string, className: string, children: HTMLElement[]): HTMLElement {
return el("section", {
className: `m02-df__part ${className}`,
children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })],
});
}
/** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */
function bigButton(label: string, onClick: () => void): HTMLElement {
return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] });
}