방식 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
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Logic_Note.ts
|
|
* 설명 카드 — 식 하나를 쉬운 말로 풀고, 그 식이 기대는 원문 표를 미리 보임(걸린 줄 강조).
|
|
*
|
|
* 순수 읽기 — `/elements` 로 표 파일을 찾고 `/table` 로 통째 읽어 그 자리에서 줄을 맞힘.
|
|
* 엔진을 다시 돌리지 않음(수량·금액은 `/calc` 답이 줌) — 여기서는 「어느 줄이 걸렸나」만 보임.
|
|
* 흐름 그림 상자(`…_Logic_Flow.ts`)와 테스트 방식 B 가 같이 씀.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { el } from "@ui/ui_template_elements";
|
|
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
|
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
|
|
|
/* ── 원문 표(소요량·계수) 읽기 ─────────────────────────────────────────── */
|
|
|
|
export 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;
|
|
}
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */
|
|
|
|
export 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]);
|
|
}
|
|
|
|
export interface RelatedFind {
|
|
/** 「이 줄」 또는 물려 쓰는 중간 값 이름 */
|
|
source: string;
|
|
find: FindCall;
|
|
}
|
|
|
|
/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */
|
|
export function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] {
|
|
const out: RelatedFind[] = findCalls(expr).map((find) => ({
|
|
source: tx("Note_ThisLine"),
|
|
find,
|
|
}));
|
|
for (const m of middles) {
|
|
if (!mentions(expr, m.이름)) continue;
|
|
for (const find of findCalls(m.식)) out.push({ source: m.이름, find });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */
|
|
|
|
export 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(값을 아직 안 골랐거나 후보 여럿) */
|
|
export 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));
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
/* ── 카드 그리기 ──────────────────────────────────────────────────────── */
|
|
|
|
export interface NoteOptions {
|
|
/** 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */
|
|
expr: string;
|
|
/** 물려 쓰는 중간 값(한 단계) */
|
|
middles: NamedFormula[];
|
|
ctx: EvalCtx;
|
|
}
|
|
|
|
/** 표 하나를 그림 — 지금 값으로 걸린 줄은 강조 */
|
|
function tableView(find: RelatedFind, table: TableRow, ctx: EvalCtx): HTMLElement[] {
|
|
const hit = matchRow(table, find.find.conds, ctx);
|
|
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
|
|
const body = (table.줄 ?? []).map((line) =>
|
|
el("tr", {
|
|
className: hit === line ? "m01c__hit" : "",
|
|
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
|
|
}),
|
|
);
|
|
const use = table.용도
|
|
? [table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")
|
|
: "";
|
|
return [
|
|
el("p", {
|
|
className: "m01c__muted",
|
|
text: `${find.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`,
|
|
}),
|
|
...(use ? [el("p", { className: "m01c__muted", text: `${tx("Use_Of")} — ${use}` })] : []),
|
|
el("table", {
|
|
className: "m01c__table",
|
|
children: [
|
|
el("thead", { children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })] }),
|
|
el("tbody", { children: body }),
|
|
],
|
|
}),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 설명 카드를 `host` 에 채움 — 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로).
|
|
* 표는 서버에서 읽어 오므로 먼저 「찾는 중」을 보이고 온 뒤 갈아 끼움.
|
|
*/
|
|
export function buildNote(host: HTMLElement, opts: NoteOptions): void {
|
|
const related = relatedFinds(opts.expr, opts.middles);
|
|
const advanced = el("details", {
|
|
className: "m01c__raw",
|
|
children: [el("summary", { text: tx("Note_Formula") }), el("pre", { text: opts.expr })],
|
|
});
|
|
const plain = explain(opts.expr);
|
|
const head: HTMLElement[] = [
|
|
el("p", { className: "m01c__muted", text: tx("Note_Plain") }),
|
|
el("p", { className: "m01c__plain", text: plain }),
|
|
];
|
|
if (!related.length) {
|
|
host.replaceChildren(...head, advanced);
|
|
return;
|
|
}
|
|
const tables = el("div", { className: "m01c__muted", text: `${tx("Note_Searching")}…` });
|
|
host.replaceChildren(
|
|
...head,
|
|
el("p", { className: "m01c__muted", text: tx("Note_Table") }),
|
|
tables,
|
|
advanced,
|
|
);
|
|
void Promise.all(related.map(async (r) => ({ r, table: await loadTable(r.find.table) }))).then(
|
|
(got) => {
|
|
tables.className = "m01c__tables";
|
|
tables.replaceChildren(
|
|
...got.flatMap(({ r, table }) =>
|
|
table
|
|
? tableView(r, table, opts.ctx)
|
|
: [
|
|
el("p", {
|
|
className: "m01c__muted",
|
|
text: tx("Note_TableMissing", { v: r.find.table }),
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|