feat(M01): 일위대가 로직 기본 보기를 흐름 그림으로 · 상자에서 보고 고치기

방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」).
상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐.
자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤.

- UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style
- UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침
- 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-21 09:35:33 +09:00
co-authored by Claude Opus 5
parent 2d24cd358e
commit 98e40f4496
13 changed files with 1213 additions and 728 deletions
+8 -240
View File
@@ -13,206 +13,20 @@
* 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createSelectField, el } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type HoLine,
type LogicOne,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */
interface TableRow {
file: string;
키: string;
이름?: string;
원문번호?: string;
출처?: string;
용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
조건?: Record<string, string>;
값칸?: Record<string, string>;
줄?: Record<string, unknown>[];
}
const tableCache = new Map<string, Promise<TableRow | null>>();
async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${API_BASE_URL}/m01${path}`);
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
function loadTable(key: string): Promise<TableRow | null> {
const cached = tableCache.get(key);
if (cached) return cached;
const job = (async (): Promise<TableRow | null> => {
for (const group of ["소요량", "계수"]) {
const found = await getJson<{ items: ElementBrief[] }>(
`/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`,
);
const file = found?.items?.find((i) => i.ref === key)?.file;
if (!file) continue;
const got = await getJson<{ table: TableRow }>(
`/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`,
);
if (got?.table) return { ...got.table, file };
}
return null;
})();
tableCache.set(key, job);
return job;
}
/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */
interface FindCall {
table: string;
conds: [string, string][];
col: string;
}
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
function splitConds(raw: string): [string, string][] {
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((p): [string, string] => {
const i = p.indexOf("=");
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
}
function findCalls(expr: string): FindCall[] {
const out: FindCall[] = [];
for (const m of expr.matchAll(FIND_RE)) {
out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] });
}
return out;
}
/** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */
function mentions(expr: string, name: string): boolean {
if (!name) return false;
const idx = expr.indexOf(name);
if (idx < 0) return false;
const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c);
return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]);
}
interface RelatedFind {
source: string; // "이 줄" 또는 물려 쓰는 중간 이름
find: FindCall;
}
/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */
function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] {
const out: RelatedFind[] = findCalls(expr).map((find) => ({ source: "이 줄", find }));
for (const m of middles) {
if (!mentions(expr, m.이름)) continue;
for (const find of findCalls(m.식)) out.push({ source: m.이름, find });
}
return out;
}
/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */
interface EvalCtx {
values: Record<string, string>;
middle: Record<string, unknown>;
}
function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined {
const quoted = name.match(/^["'](.*)["']$/);
if (quoted) return quoted[1];
if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name);
if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name];
if (ctx.middle[name] !== undefined) return ctx.middle[name] as number;
return undefined;
}
function sameValue(a: unknown, b: unknown): boolean {
if (a === undefined || a === null || b === undefined) return false;
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb;
return String(a) === String(b);
}
/** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */
function matchRow(
table: TableRow,
conds: [string, string][],
ctx: EvalCtx,
): Record<string, unknown> | null {
for (const row of table.줄 ?? []) {
if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row;
}
return null;
}
/* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */
function splitTop(s: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur);
cur = "";
} else cur += ch;
}
out.push(cur);
return out;
}
function expandIf(expr: string): string {
const at = expr.indexOf("만약(");
if (at < 0) return expr;
let depth = 0;
let j = at + 2; // "만약(" 의 '(' 자리
for (; j < expr.length; j++) {
if (expr[j] === "(") depth++;
else if (expr[j] === ")") {
depth--;
if (depth === 0) {
j++;
break;
}
}
}
const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim());
const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`;
return expandIf(expr.slice(0, at) + said + expr.slice(j));
}
function explain(expr: string): string {
const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => {
const cs = splitConds(conds)
.map(([, rhs]) => rhs)
.join(" · ");
return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`;
});
return expandIf(withFind).trim() || expr;
}
/* ── 화면 상태 ────────────────────────────────────────────────────────── */
interface State {
@@ -357,7 +171,7 @@ function paint(host: HTMLElement, state: State): void {
const side = el("div", { className: "m01-logic__side" });
side.style.cssText =
"flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;";
void buildCard(side, state);
buildCard(side, state);
const layout = el("div", {});
layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;";
@@ -429,7 +243,7 @@ function buildHoTable(host: HTMLElement, state: State): HTMLElement {
});
}
async function buildCard(side: HTMLElement, state: State): Promise<void> {
function buildCard(side: HTMLElement, state: State): void {
const ho: HoLine | undefined = (state.one.logic.호표 ?? [])[state.selected];
if (!ho) {
side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" }));
@@ -456,7 +270,6 @@ async function buildCard(side: HTMLElement, state: State): Promise<void> {
el("dd", { text: calc?.출처 ?? ho.비목 ?? "-" }),
],
}),
el("p", { text: explain(ho.수량) }),
);
const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증"));
@@ -471,53 +284,8 @@ async function buildCard(side: HTMLElement, state: State): Promise<void> {
);
}
side.append(el("h4", { text: "원문 표 미리보기" }));
const previewHost = el("div", { text: "찾는 중…" });
side.append(previewHost);
const previews = await Promise.all(
related.map(async (r) => ({ r, table: await loadTable(r.find.table) })),
);
previewHost.replaceChildren(
...previews.flatMap(({ r, table }) => {
if (!table)
return [el("p", { className: "m01-logic__muted", text: `${r.find.table} 표 못 찾음` })];
const hit = matchRow(table, r.find.conds, ctx);
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
const bodyRows = (table.줄 ?? []).map((line) => {
const isHit = hit === line;
const tr = el("tr", {
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
});
if (isHit) tr.style.cssText = "background:var(--ui-accent-bg,#fff3cd);font-weight:600;";
return tr;
});
return [
el("p", {
className: "m01-logic__muted",
text: `${r.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`,
}),
...(table.용도
? [
el("p", {
className: "m01-logic__muted",
text: `${tt("Use_Of")} — ${[table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")}`,
}),
]
: []),
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
}),
el("tbody", { children: bodyRows }),
],
}),
];
}),
);
const advanced = el("details", {});
advanced.append(el("summary", { text: "고급 — 식 그대로" }), el("pre", { text: ho.수량 }));
side.append(advanced);
// 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드
const note = el("div");
side.append(note);
buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx });
}