/* ============================================================================= * M02_DF_Entry_Detail.ts * 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름) * · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움. * 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { CfAnswer, CfQuery } from "./M02_DF_Api"; import type { CfBreak, CfValues } from "./M02_DF_Eval"; import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model"; const TEXT = { Detail: ["상세 내용", "Details"], Settings: ["설정", "Settings"], Body: ["본문", "Content"], Warn: ["경고", "Warnings"], WarnNone: ["경고 없음", "No warnings"], PickSome: [ "다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임", "Choose a box, pill or line in the diagram", ], Line: ["선", "Line"], In: ["받는 알약", "Incoming pill"], Out: ["내보내는 알약", "Outgoing pill"], } as const; const tt = (key: keyof typeof TEXT): string => (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; /** 고른 것 — 상자 · 선 · 알약 */ export type DfPick = | { kind: "box"; id: string } | { kind: "link"; id: string } | { kind: "pill"; box: string; port: string; dir: "in" | "out" } | null; export interface DetailParts { root: HTMLElement; title: HTMLElement; pill: HTMLElement; hint: HTMLElement; settings: HTMLElement; body: HTMLElement; warn: HTMLElement; } const row = (label: string, host: HTMLElement): HTMLElement => el("div", { className: "m02-df__detail-row", children: [el("h4", { text: label }), host], }); /** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */ export function detailFrame(): DetailParts { const title = el("strong", { className: "m02-df__detail-title" }); const pill = el("span", { className: "m02-df__detail-pill" }); const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") }); const settings = el("div", { className: "m02-df__detail-settings" }); const body = el("div", { className: "m02-df__detail-body" }); const warn = el("ul", { className: "m02-df__detail-warn" }); const root = el("section", { className: "m02-df__part m02-df__detail", children: [ el("div", { className: "m02-df__detail-head", children: [ el("h3", { text: tt("Detail") }), el("div", { className: "m02-df__detail-name", children: [title, pill] }), ], }), el("div", { className: "m02-df__part-body", children: [ hint, row(tt("Settings"), settings), row(tt("Body"), body), row(tt("Warn"), warn), ], }), ], }); return { root, title, pill, hint, settings, body, warn }; } const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; /** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */ export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void { d.hint.hidden = !!pick; for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick; const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined; d.title.textContent = pick?.kind === "box" ? boxName(lg, pick.id) : pick?.kind === "pill" ? boxName(lg, pick.box) : link ? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}` : ""; d.pill.textContent = pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : ""; d.pill.hidden = pick?.kind !== "pill"; } /** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */ export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void { const mine = all.filter((k) => pick?.kind === "box" ? k.box === pick.id : pick?.kind === "pill" ? k.box === pick.box && k.port === pick.port && !k.link : pick?.kind === "link" ? k.link === pick.id : false, ); d.warn.replaceChildren( ...(mine.length ? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w })) : [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]), ); } /** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */ export async function loadLogics( lg: CfLogic, get: (key: string) => Promise, ): Promise<{ logics: Record; errors: string[] }> { const logics: Record = {}; const errors: string[] = []; const keysOf = (x: CfLogic): string[] => x.boxes.flatMap((b) => b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [], ); const seen = new Set(lg.key ? [lg.key] : []); let todo = keysOf(lg); while (todo.length) { const key = todo.shift()!; if (seen.has(key)) continue; seen.add(key); try { const got = await get(key); if (!got) throw new Error(key); logics[key] = got; todo = [...todo, ...keysOf(got)]; } catch (e) { errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`); } } return { logics, errors }; } /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ export async function unitChoices(): Promise { const count = new Map(); 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 : ["원/㎡"]; } /** 재료 검색어 틀(「레미콘 {a}-{b}」)의 자리에 알약 값 — 빈 자리가 있으면 null(서버 Compute `_fill_template` 과 같음) */ function fillTemplate( rule: NonNullable, pill: (p: CfPort) => unknown, ): unknown { const got = new Map((rule.slots ?? []).map((x) => [x.name, pill(x.from)])); if ([...got.values()].some((v) => v === null || v === undefined)) return null; return (rule.template ?? "").replace(/\{([^{}]+)\}/g, (m, k: string) => got.has(k) ? String(got.get(k)) : m, ); } /** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */ const RULE_GROUPS = ["인력", "기계", "유가전력"]; /** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을 * pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」) * · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ export function queriesOf( lg: CfLogic, pill: (p: CfPort) => unknown = () => null, elementPort: (e: CfElementRef) => string = (e) => e.name, ): { q: CfQuery; box: string; port: string }[] { return lg.boxes.flatMap((b) => { const inp = b.input; if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) { const ref = inp.logic; const designs: Record = {}; for (const d of ref.designs) { const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name); const v = l ? pill(l.from) : ref.examples?.[d.name]; if (v !== null && v !== undefined) designs[d.name] = v; } const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } }; return [{ q, box: b.id, port: "*" }]; } if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; const group = inp.group; const slots = inp.elements .filter( (e) => (group === "재료" && e.mode && e.mode !== "fixed") || (RULE_GROUPS.includes(group) && e.mode === "rule"), ) .map((e) => { const port = elementPort(e); const q: CfQuery = { id: `${b.id}\u0001${port}`, group, unit: e.unit, slot: { ...e, field: e.rule?.field, op: e.rule?.op }, }; if (e.mode === "rule" && e.rule) q.value = e.rule.field === "검색어" ? fillTemplate(e.rule, pill) : pill(e.rule.from); return { q, box: b.id, port }; }); const els = inp.elements .filter((e) => e.ref && !e.ref.startsWith("UE-") && !slots.some((s) => s.port === e.name)) .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 [...slots, ...els, ...table]; }); } /** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ export async function fetchValues( ask: (qs: CfQuery[], els: never) => Promise, lg: CfLogic, pill?: (p: CfPort) => unknown, elementPort?: (e: CfElementRef) => string, ): Promise<{ values: CfValues; errors: string[] }> { const all = queriesOf(lg, pill, elementPort); const asks = all.filter((a) => !a.q.logic); const values: CfValues = {}; const errors: string[] = []; // 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값 const logicAsks = all.filter((a) => a.q.logic); const logicDone = logicAsks.length ? import("./M02_DF_Api").then(({ cfLogicValue }) => Promise.all( logicAsks.map((a) => cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then( (got) => { values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) }; }, (e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`), ), ), ), ) : Promise.resolve(); if (!asks.length) { await logicDone; return { values, errors }; } const answers = await ask( asks.map((a) => a.q), lg.userElements as never, ); await logicDone; 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 }; }