feat(m02-df): 흐름도 잇기 — 고름 · 상세 내용 판 · 흐름 길 · 끊긴 곳 · 다른 로직 · 표준 표

- 고름 = 상자 · 알약{box,port,dir} · 선 — 다이어그램 · 검증 칸 줄 · 알약 판 상자 이름 누름 모두 같은 고름으로
- 상세 내용 본문 = 입력 · 함수 · 출력 판 · 알약 = renderPillPanel · 선 = renderLinkPanel · 경고 = 끊긴 곳 가운데 고른 것 몫
- 알약 · 선 고름 = Model traceBack/traceForward 로 Diagram opts.trace · Eval breaks 로 Diagram opts.breaks
- 다른 로직 입력이 부르는 로직(저장 DF- · 견본)을 겹쳐 읽어 Eval opts.logics 로(셈 · 검사 · 판 모두)
- 「표준 표」 단추 = renderStandards 판(공용 모달)
- 입력 값 물음 · 결과 단위 후보는 Entry_Detail 로 옮김(700 줄 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-01 20:00:56 +09:00
co-authored by Claude Opus 5.5
parent 314783a027
commit ce65d1df3b
2 changed files with 227 additions and 186 deletions
+127 -116
View File
@@ -18,17 +18,21 @@ import {
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 { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfListItem, CfQuery } from "./M02_DF_Api";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model";
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,
lineBody,
pillBody,
fetchValues,
loadLogics,
queriesOf,
unitChoices,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
@@ -45,8 +49,10 @@ const loadMods = () =>
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_Rule"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({
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,
@@ -56,7 +62,9 @@ const loadMods = () =>
summary,
sample,
input,
rule,
pill,
line,
std,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
@@ -81,6 +89,8 @@ const TEXT = {
Diagram: ["다이어그램", "Diagram"],
Summary: ["원가", "Cost"],
Color: ["상자 색", "Box colour"],
Standards: ["표준 표", "Standards"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
@@ -113,63 +123,6 @@ const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
async function unitChoices(): Promise<string[]> {
const count = new Map<string, number>();
try {
for (const x of await fetchLogics()) {
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
}
} catch {
/* 못 받으면 기본 하나 */
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
}
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
.map((e) => {
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
if (field) q.field = field;
return { q, box: b.id, port: e.name };
});
const table = inp.table
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
: [];
return [...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg);
if (!asks.length) return { values: {}, errors: [] };
const answers = await M.api.cfValues(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
}
return { values, errors };
}
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
@@ -330,6 +283,10 @@ function openDataFlow(): void {
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;
@@ -355,15 +312,15 @@ function openDataFlow(): void {
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const lineHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(colorRow);
detail.body.append(inHost, funcHost, outHost, lineHost, pillHost);
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values);
return M.ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
@@ -387,7 +344,7 @@ function openDataFlow(): void {
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M, lg).then(
void fetchValues(M.api.cfValues, lg).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
@@ -401,6 +358,54 @@ function openDataFlow(): void {
);
};
/** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */
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"),
@@ -417,18 +422,16 @@ function openDataFlow(): void {
});
const diagramOn = {
select: (id: string | null): void => {
pick = !id
? null
: lg.links.some((l) => l.id === id)
? { kind: "link", id }
: { kind: "box", id };
drawPanel();
},
/** 알약 고름 — 다이어그램(sub8)이 알려 주면 상세 내용이 알약 판으로 */
pill: (p: { box: string; name: string; dir: "in" | "out" } | null): void => {
pick = p ? { kind: "pill", ...p } : null;
drawPanel();
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 => {
@@ -455,35 +458,20 @@ function openDataFlow(): void {
const box = lg.boxes.find((b) => b.id === pickedBox());
drawHead(detail, lg, pick);
colorRow.hidden = !box;
lineHost.hidden = pick?.kind !== "link";
linkHost.hidden = pick?.kind !== "link";
pillHost.hidden = pick?.kind !== "pill";
if (pick?.kind === "link") {
const linkId = pick.id;
const rule = lg.links.find((l) => l.id === linkId)?.rule;
let text: string | null = null;
try {
text = rule ? M.rule.ruleLabel(rule) : null;
} catch {
text = rule ? rule.kind : null;
}
lineBody(lineHost, lg, pick.id, text);
}
if (pick?.kind === "pill") {
const port = { box: pick.box, name: pick.name };
pillBody(
if (pick?.kind === "link")
M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
M.pill.renderPillPanel(
pillHost,
lg,
() => M.model.traceBack(lg, port),
() => M.model.traceForward(lg, port),
pick.box,
pick.port,
{ pick: (id) => choose({ box: id }) },
{ values, logics },
);
}
let checks: string[] = [];
try {
checks = M.ev.checkLogic(lg, values);
} catch {
checks = [];
}
drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]);
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";
@@ -497,23 +485,34 @@ function openDataFlow(): void {
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;
const selected =
pick?.kind === "box" || pick?.kind === "link" ? pick.id : (pick?.box ?? null);
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
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();
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
(M.summary.renderSummary as (...args: unknown[]) => void)(
M.summary.renderSummary(
summaryHost,
lg,
result,
{ change: edit },
{ change: edit, select: choose },
{ values },
);
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
@@ -524,7 +523,7 @@ function openDataFlow(): void {
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values);
problems = M.ev.checkLogic(lg, values, { logics });
} catch {
problems = [];
}
@@ -588,6 +587,18 @@ function openDataFlow(): void {
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() }),
}),
}),
undo,
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
deleteButton,