diff --git a/M01_MasterData/M01_MasterData_Equation.py b/M01_MasterData/M01_MasterData_Equation.py index fa08f998f..7b390fdab 100644 --- a/M01_MasterData/M01_MasterData_Equation.py +++ b/M01_MasterData/M01_MasterData_Equation.py @@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store_Make as make _ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 +import master_equation_link as link # noqa: E402 import master_equation_edit as me # noqa: E402 @@ -81,6 +82,11 @@ def preview(key: str, edits: list, inputs: dict | None = None) -> dict: return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]} +def links(ask: dict) -> dict: + """새 기호 연결 후보(800-3) — {갈래: 표 | 요소 | 로직, 검색, 분류?} → {후보, 더있음} · 읽기만.""" + return link.candidates(store.loaded()[1], ask) + + def save( key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False ) -> dict: diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 50741d008..e55251cb7 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -372,6 +372,18 @@ def post_logic_equation_preview(body: EquationBody) -> dict: return _call(equation.preview, body.key, body.고침, body.inputs) +class LinksBody(BaseModel): + 갈래: str + 검색: str = "" + 분류: str = "인력" + + +@router.post("/logic/equation/links") +def post_logic_equation_links(body: LinksBody) -> dict: + """새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만).""" + return _call(equation.links, body.model_dump()) + + @router.post("/logic/equation/save") def post_logic_equation_save(body: EquationBody) -> dict: """수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409).""" diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css index 26c6401ea..982f2475a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css @@ -67,3 +67,47 @@ .m01-eq__why { margin-top: var(--spacing-8); } + +/* 800-2 — 왼쪽 수식 영역 · 오른쪽 변수 정의 영역 */ +.m01-eq__split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem); + gap: var(--spacing-12); + align-items: start; +} + +.m01-eq__left { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +@container (max-width: 700px) { + .m01-eq__split { + grid-template-columns: minmax(0, 1fr); + } + + .m01-eq__split > .ui-eq__defs { + border-left: 0; + border-top: 1px solid var(--color-border); + } +} + +.m01-eq__def-edit, +.m01-eq__draft { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.m01-eq__def-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-8); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index f6a7a6f79..348cc5e5a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -3,8 +3,10 @@ * M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이). * 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1) * 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만) - * 고침 = 줄을 고르면 그 줄만 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사) + 숨은 아이콘 줄(분수 · 괄호 · 첨자 · %) - * · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) + * 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음) + * 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭) + * · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서 + * · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) * 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게) * 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] @@ -13,16 +15,18 @@ import { API_BASE_URL } from "@config/config_frontend"; import { createBanner } from "@ui/ui_template_banner"; import { createBadge } from "@ui/ui_template_badge"; -import { createButton, createIconButton } from "@ui/ui_template_button"; +import { createButton } from "@ui/ui_template_button"; import { createEqChain } from "@ui/ui_template_equation_line"; +import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs"; import { createEqSum } from "@ui/ui_template_equation_sum"; import { eqText, type EqLine, type EqSumHooks, + type EqNode, type EqTree, - type EqVarRow, } from "@ui/ui_template_equation"; +import { lineId } from "@ui/ui_template_equation_line"; import { el, hideLoadingOverlay, @@ -38,15 +42,15 @@ import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_form import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; -import { openAddDialog, openVarDialog } from "./M01_MasterData_UI_LogicLab_EquationVar"; -import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { - formulaEdit, - inputsOf, - insertInto, - type EqEdit, - type InsertKind, -} from "./logic/M01_Logic_Equation_Model"; + draftRow, + renameEditor, + type Draft, + type DefsHost, +} from "./M01_MasterData_UI_LogicLab_EquationDefs"; +import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar"; +import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; +import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model"; import "./M01_MasterData_UI_LogicLab_Equation.css"; const TEXT = { @@ -65,13 +69,11 @@ const TEXT = { ], Options: ["옵션", "Options"], Wait: ["수식을 불러오는 중", "Loading equation"], - Close: ["식 닫기", "Close formula"], - Hint: ["줄을 눌러 고침 · 키보드 + − × ÷ / ( )", "Click a line to edit · keyboard + − × ÷ / ( )"], - Frac: ["분수 (위) / (아래)", "Fraction"], - Paren: ["괄호", "Parentheses"], - Sub: ["아래첨자 _", "Subscript"], - Sup: ["위첨자 ^", "Superscript"], - Percent: ["% (/ 100)", "Percent"], + Hint: [ + "식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )", + "Click a formula to edit · keyboard + − × ÷ / ( )", + ], + Apply: ["연결 적용 · 식 넣기", "Apply links and formula"], Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"], Leave_Ok: ["버림", "Discard"], Saved: ["수식을 저장함", "Equation saved"], @@ -117,7 +119,7 @@ const loadCache = (): Record => { /** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */ export interface EqPage { ask: ( - suffix: "" | "/preview", + suffix: "" | "/preview" | "/links", body: Record, signal?: AbortSignal, ) => Promise; @@ -140,6 +142,15 @@ export interface LabEquationOptions { page?: EqPage; } +/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */ +interface PendingLink { + line: EqLine; + text: string; + symbols: string[]; + rows: Map; + apply: HTMLElement; +} + export function createLabEquation(opt: LabEquationOptions) { const bar = el("div", { className: "m01-eq__bar" }); const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } }); @@ -159,7 +170,10 @@ export function createLabEquation(opt: LabEquationOptions) { let inputs: Record = {}; let before: Record | null = null; let why = ""; - let selected: string | null = null; + let editingLine: string | null = null; + let defEditing: string | null = null; + let pending: PendingLink | null = null; + let defsBox: HTMLElement = el("div"); let ctl: AbortController | null = null; let timer = 0; let paintedBlocked = false; @@ -168,7 +182,11 @@ export function createLabEquation(opt: LabEquationOptions) { const canEdit = (): boolean => opt.admin && !opt.blocked(); const page = opt.page; const vals = (): Record => page?.values() ?? inputs; - const ask = (suffix: "" | "/preview", body: Record, signal?: AbortSignal) => + const ask = ( + suffix: "" | "/preview" | "/links", + body: Record, + signal?: AbortSignal, + ) => page ? page.ask(suffix, body, signal) : post(`/logic/equation${suffix}`, body, signal); const body0 = (): Record => ({ key, inputs: inputsOf(vals()), 고침: edits }); @@ -302,14 +320,97 @@ export function createLabEquation(opt: LabEquationOptions) { ); } - // ── 고침 도구 ────────────────────────────────────────────────────── - const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [ - ["frac", "a⁄b", "Frac"], - ["paren", "( )", "Paren"], - ["sub", "x₂", "Sub"], - ["sup", "x²", "Sup"], - ["percent", "%", "Percent"], - ]; + // ── 바로 고침 · 연결 지정 ────────────────────────────────────────── + const total = (sums: Record): string => ("계" in sums ? "계" : "결과"); + + const defsHost: DefsHost = { + commit, + links: (body) => ask("/links", body), + onDropped: (k) => { + if (page) page.setValue(k, null); + else { + delete inputs[k]; + drawOptions(); + } + }, + }; + + function buildDefs(): HTMLElement { + const edit = canEdit(); + const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠); + return createEqDefs(eq?.정의 ?? [], { + editing: defEditing, + onEdit: edit + ? (d) => { + defEditing = defEditing === defSign(d) ? null : defSign(d); + drawDefs(); + } + : undefined, + editor: (d) => + renameEditor(d, defsHost, () => { + defEditing = null; + drawDefs(); + }), + drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [], + head: edit + ? [ + createButton({ + label: t("Add"), + variant: "ghost", + size: 24, + iconStart: "plus", + onClick: () => openAddDialog({ mids, commit }), + }), + ] + : [], + }); + } + + function drawDefs(): void { + const next = buildDefs(); + defsBox.replaceWith(next); + defsBox = next; + } + + /** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */ + function setPending(line: EqLine, text: string, miss: string[]): void { + if (!miss.length) { + if (!pending) return; + pending = null; + return drawDefs(); + } + if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) { + [pending.line, pending.text] = [line, text]; + return; + } + const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const)); + const apply = el("div", { + className: "m01-eq__def-actions", + children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })], + }); + pending = { line, text, symbols: miss, rows, apply }; + drawDefs(); + defsBox.scrollTo({ top: 0 }); + } + + async function applyPending(): Promise { + const p = pending; + if (!p) return; + const list: EqEdit[] = []; + let missing = false; + for (const d of p.rows.values()) { + const made = d.read(); + d.say(typeof made === "string" ? made : ""); + if (typeof made === "string") missing = true; + else list.push(made); + } + if (missing) return; + const bad = await commit([...list, formulaEdit(p.line, p.text)]); + if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · "))); + drafts.delete(lineId(p.line)); + [pending, editingLine] = [null, null]; + paint(); + } const check = (line: EqLine) => @@ -319,19 +420,26 @@ export function createLabEquation(opt: LabEquationOptions) { { ...body0(), 고침: [...edits, formulaEdit(line, text)] }, signal, ); + const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []); + setPending(line, text, miss); const now = got.후 ? eqText(got.후[total(got.후)]) : ""; return { 되나: !!got.되나, - 까닭: got.까닭 ?? [], + 까닭: miss.length + ? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`] + : (got.까닭 ?? []), 알림: got.되나 && now ? [`${t("Total")} ${now}`] : [], 식: got.되나 ? text : null, }; }; - const total = (sums: Record): string => ("계" in sums ? "계" : "결과"); - + /** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */ function editor(line: EqLine, id: string): HTMLElement { const shown = line.입력글 ?? line.식; + const close = (): void => { + [editingLine, pending] = [null, null]; + paint(); + }; const cell = createFormulaCell({ key: id, spot: id, @@ -340,82 +448,28 @@ export function createLabEquation(opt: LabEquationOptions) { drafts, check: check(line), put: (text) => { - if (text === null) return; + if (text === null) return pending ? undefined : close(); void commit([formulaEdit(line, text)]).then((bad) => { if (bad.length) showToast(bad.join(" · "), "error"); + else close(); }); }, className: "m01-eq__fx", }); - const input = (): HTMLInputElement | null => cell.querySelector("input"); - const icons = ICONS.map(([kind, glyph, label]) => - createIconButton({ - glyph, - title: t(label), - size: 24, - onClick: () => { - const box = input(); - if (!box) return; - const from = box.selectionStart ?? box.value.length; - const got = insertInto(kind, box.value, from, box.selectionEnd ?? from); - box.value = got.text; - box.dispatchEvent(new Event("input", { bubbles: true })); - box.focus(); - box.setSelectionRange(got.caret, got.caret); - }, - }), + const input = cell.querySelector("input"); + // 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로 + cell.addEventListener("focusout", () => + window.setTimeout(() => { + if (editingLine === id && !pending && !cell.contains(document.activeElement)) { + if ((input?.value.trim() ?? shown) === shown) close(); + } + }, 120), ); - const add = createButton({ - label: t("Add"), - variant: "ghost", - size: 24, - iconStart: "plus", - onClick: () => - openAddDialog({ - mids: (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠), - commit, - }), - }); - const close = createButton({ - label: t("Close"), - variant: "ghost", - size: 24, - onClick: () => ((selected = null), paint()), - }); - // 아이콘 줄 — 고른 줄에만(평소 숨김) - const tools = createToolbar({ - items: [...icons, "divider", add, "spacer", close], - size: 24, - label: t("Hint"), - }); - tools.dataset.eqTools = id; - const wrap = el("div", { className: "m01-eq__edit", children: [cell, tools] }); - wrap.dataset.eqEditor = id; - queueMicrotask(() => input()?.focus()); - return wrap; + queueMicrotask(() => input?.focus()); + return cell; } - function varDialog(node: { 열쇠?: string }): void { - const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠); - if (!v || !canEdit()) return; - openVarDialog({ - row: v, - value: vals()[v.열쇠] ?? "", - commit, - onValue: (value) => { - if (page) return page.setValue(v.열쇠, value || null); - if (value) inputs[v.열쇠] = value; - else delete inputs[v.열쇠]; - drawOptions(); - void refresh(); - }, - onDropped: (k) => { - if (page) return page.setValue(k, null); - delete inputs[k]; - drawOptions(); - }, - }); - } + const showDef = (node: EqNode): void => flashEqDef(defsBox, node); // ── 그림 ────────────────────────────────────────────────────────── function drawBar(): void { @@ -466,24 +520,31 @@ export function createLabEquation(opt: LabEquationOptions) { return; } const edit = canEdit(); - const sum = createEqSum(eq, open, page?.sum); - body.replaceChildren( - createEqChain(eq.줄, { - path: "", - open, - selected, - resultUnit: eq.결과단위, - onSelect: edit - ? (_line, id) => { - selected = selected === id ? null : id; - paint(); - } - : undefined, - onVar: edit ? varDialog : undefined, - slot: edit ? editor : undefined, - }), - ...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계 - ); + const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠); + const sum = createEqSum(eq, open, page?.sum, keys); + const left = el("div", { + className: "m01-eq__left", + children: [ + createEqChain(eq.줄, { + path: "", + open, + resultUnit: eq.결과단위, + onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함) + editing: editingLine, + onEdit: edit + ? (_line, id) => { + editingLine = id; + paint(); + } + : undefined, + editor: edit ? editor : undefined, + after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null), + }), + ...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계 + ], + }); + defsBox = buildDefs(); + body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] })); } async function save(): Promise { @@ -494,7 +555,7 @@ export function createLabEquation(opt: LabEquationOptions) { post("/logic/equation/save", { ...body0(), version, 개정확인: ok }), ); if (!done) return; // 개정 확인 취소 = 고침 그대로 - [edits, selected] = [[], null]; + [edits, editingLine] = [[], null]; persist(); showToast(t("Saved"), "success"); await refresh(); @@ -514,7 +575,7 @@ export function createLabEquation(opt: LabEquationOptions) { async function discard(): Promise { edits = []; - selected = null; + [editingLine, defEditing, pending] = [null, null, null]; drafts.clear(); persist(); await refresh(); @@ -529,7 +590,8 @@ export function createLabEquation(opt: LabEquationOptions) { version = nextVersion; row = nextRow; if (nextKey !== key) { - [key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null]; + [key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null]; + [defEditing, pending] = [null, null]; inputs = {}; edits = page ? page.edits() : (loadCache()[nextKey] ?? []); open.clear(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts new file mode 100644 index 000000000..72712c742 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts @@ -0,0 +1,323 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_EquationDefs.ts + * 수식 탭 변수 정의 영역 속 고침(PLAN 800-3 · 요구 660-5) — 796-2a 변수 창을 정의 영역 안으로 옮겨 다시 씀. + * ① 이름 · 기호 · 첨자 고침 · 지움 = 정의 줄 아래 칸(`renameEditor`) — 쓰는 변수 지움은 서버가 막고 쓰는 곳을 알림 + * ② 새 기호 연결 지정 = 식에 처음 쓴 기호마다 줄(`draftRow`) — 국문 이름 + 연결(입력 · 표 찾기 · 요소 · 로직 · 상수) → 고침 `연결` 한 건 + * 표 · 요소 · 로직 후보 = 서버 `…/equation/links`(읽기만) · 연결 글(엔진 식)은 서버가 지음 — 화면은 고른 것만 보냄 + * 기호 글 쪼개기(`symbolParts`) = 서버 키보드 식 변수 글(`master_equation_edit._TOKEN` 기호_아래^위)과 같은 꼴 — 시험 `test_800_2_equation_layout` + * 값 · 단위는 고치지 않음(요구 660-6) · 계산 · 검사는 서버 한 벌 + * ========================================================================== */ + +import type { EqDef } from "@ui/ui_template_equation"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements_base"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { varEdits, type EqEdit } from "./logic/M01_Logic_Equation_Model"; + +export interface Candidate { + 키: string; + 이름: string; + 규격?: string; + 조건?: string[]; + 칸?: string[]; + 입력?: string[]; +} + +export interface DefsHost { + commit: (edits: EqEdit[]) => Promise; + links: (ask: Record) => Promise<{ 후보: Candidate[]; 더있음: boolean }>; + onDropped: (key: string) => void; +} + +/** 새 기호 글 → 열쇠 · 기호 · 아래 · 위(`Q_d^2` → 열쇠 `Q_d2` · 기호 Q · 아래 d · 위 2) */ +export function symbolParts(token: string): Pick { + const [main = "", up = ""] = token.split("^"); + const [sign = "", ...low] = main.split("_"); + return { 열쇠: token.replace(/[^\p{L}\p{N}_]/gu, ""), 기호: sign, 아래: low.join("_"), 위: up }; +} + +const grid = (...kids: HTMLElement[]): HTMLElement => + el("div", { className: "m01-eq__form", children: kids }); + +const field = (label: string, value = ""): ReturnType => + createInputField({ label, value, size: 24 }); + +const reasonBox = (): { root: HTMLElement; say: (list: string[]) => void } => { + const root = el("div", { className: "m01-eq__why" }); + return { + root, + say: (list) => + root.replaceChildren( + ...(list.length + ? [createBanner({ tone: "danger", title: "안 됨", text: list.join(" · ") })] + : []), + ), + }; +}; + +/** 정의 줄 고침 칸 — 이름 · 기호 · 위 · 아래 · [적용] · [변수 지움] */ +export function renameEditor(d: EqDef, host: DefsHost, close: () => void): HTMLElement { + const name = field("이름", d.이름); + const sign = field("기호", d.기호); + const up = field("위첨자", d.위); + const down = field("아래첨자", d.아래); + const why = reasonBox(); + const form = (v: Pick) => ({ + 열쇠: d.열쇠, + ...v, + 단위: "", + }); + const apply = createButton({ + label: "적용", + size: 24, + onClick: async () => { + const edits = varEdits( + form(d), + form({ + 이름: name.input.value.trim(), + 기호: sign.input.value.trim(), + 위: up.input.value.trim(), + 아래: down.input.value.trim(), + }), + ); + const bad = edits.length ? await host.commit(edits) : []; + why.say(bad); + if (!bad.length) close(); + }, + }); + const drop = createButton({ + label: "변수 지움", + variant: "danger-ghost", + size: 24, + onClick: async () => { + const bad = await host.commit([{ 할일: "지움", 열쇠: d.열쇠 }]); + why.say(bad); + if (!bad.length) host.onDropped(d.열쇠); + }, + }); + const shut = createButton({ label: "닫기", variant: "ghost", size: 24, onClick: close }); + const box = el("div", { + className: "m01-eq__def-edit", + children: [ + grid(name.root, sign.root, up.root, down.root), + el("div", { className: "m01-eq__def-actions", children: [drop, shut, apply] }), + why.root, + ], + }); + box.dataset.eqDefEditor = d.기호; + return box; +} + +export interface Draft { + root: HTMLElement; + /** 연결 지정 고침 — 아직 덜 골랐으면 까닭 글 */ + read: () => EqEdit | string; + say: (why: string) => void; +} + +const KINDS = [ + { value: "입력", text: "입력 (옵션 칸에 생김)" }, + { value: "표", text: "표 찾기 (표 · 조건 · 칸)" }, + { value: "요소", text: "요소 (인력 · 재료 · 기계)" }, + { value: "로직", text: "로직 부르기" }, + { value: "상수", text: "상수 (고정 수)" }, +]; +const CATS = [ + { value: "인력", text: "인력" }, + { value: "재료", text: "재료" }, + { value: "기계", text: "기계" }, +]; + +/** 후보 찾기 — 검색 글을 칠수록 서버 후보 · 고르기 */ +function pick( + host: DefsHost, + ask: () => Record, + onPick: (c: Candidate | null) => void, + label: string, +): { root: HTMLElement; get: () => Candidate | null; refresh: () => void } { + const box = field(label); + const list = createSelectField({ + options: [{ value: "", text: "— 검색해서 고름 —" }], + size: 24, + ariaLabel: label, + }); + let found: Candidate[] = []; + let timer = 0; + const get = (): Candidate | null => found[Number(list.select.value)] ?? null; + const refresh = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const got = await host.links({ ...ask(), 검색: box.input.value.trim() }).catch(() => null); + found = got?.후보 ?? []; + const more = got?.더있음 ? [{ value: "", text: "… 더 있음 — 검색을 더 칠 것" }] : []; + list.setOptions( + [ + { value: "", text: found.length ? "— 고름 —" : "— 없음 —" }, + ...found.map((c, i) => ({ + value: String(i), + text: `${c.이름}${c.규격 ? ` · ${c.규격}` : ""}`, + })), + ...more, + ], + "", + ); + onPick(null); + }, 250); + }; + box.input.addEventListener("input", refresh); + list.select.addEventListener("change", () => onPick(list.select.value === "" ? null : get())); + return { + root: grid(box.root, list.root), + get: () => (list.select.value === "" ? null : get()), + refresh, + }; +} + +type Reader = () => EqEdit | string; + +const inputKind = (): { nodes: HTMLElement[]; read: Reader } => { + const base = field("기본값 (빈 칸 = 옵션에서 고름)"); + const text = (): string => base.input.value.trim(); + return { + nodes: [base.root], + read: () => ({ 할일: "연결", 갈래: "입력", ...(text() ? { 기본값: text() } : {}) }), + }; +}; + +const constKind = (): { nodes: HTMLElement[]; read: Reader } => { + const val = field("값"); + return { + nodes: [val.root], + read: () => + val.input.value.trim() + ? { 할일: "연결", 갈래: "상수", 값: val.input.value.trim() } + : "상수 값을 적을 것", + }; +}; + +const elementKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { + const cat = createSelectField({ label: "분류", options: CATS, value: "인력", size: 24 }); + const find = pick( + host, + () => ({ 갈래: "요소", 분류: cat.select.value }), + () => undefined, + "요소 이름", + ); + cat.select.addEventListener("change", find.refresh); + find.refresh(); + return { + nodes: [cat.root, find.root], + read: () => { + const one = find.get(); + return one ? { 할일: "연결", 갈래: "요소", 요소: one.키 } : "요소를 고를 것"; + }, + }; +}; + +const logicKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { + const sub = grid(); + const args = new Map>(); + const find = pick( + host, + () => ({ 갈래: "로직" }), + (c) => { + args.clear(); + sub.replaceChildren( + ...(c?.입력 ?? []).map((n) => { + const f = field(`${n} = (식 · 빈 칸 = 기본)`); + args.set(n, f); + return f.root; + }), + ); + }, + "로직 이름", + ); + return { + nodes: [find.root, sub], + read: () => { + const one = find.get(); + if (!one) return "부를 로직을 고를 것"; + const 인자 = Object.fromEntries([...args].map(([n, f]) => [n, f.input.value.trim()])); + return { 할일: "연결", 갈래: "로직", 로직: one.키, 인자 }; + }, + }; +}; + +const tableKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => { + const sub = grid(); + const conds = new Map>(); + const cols = createSelectField({ label: "칸", options: [{ value: "", text: "—" }], size: 24 }); + const find = pick( + host, + () => ({ 갈래: "표" }), + (c) => { + conds.clear(); + cols.setOptions( + [{ value: "", text: "— 칸 —" }, ...(c?.칸 ?? []).map((v) => ({ value: v, text: v }))], + "", + ); + sub.replaceChildren( + ...(c?.조건 ?? []).map((n) => { + const f = field(`조건 ${n} = (변수 기호 · 식)`); + conds.set(n, f); + return f.root; + }), + ); + }, + "표 이름", + ); + find.refresh(); + return { + nodes: [find.root, cols.root, sub], + read: () => { + const one = find.get(); + if (!one || !cols.select.value) return "표와 칸을 고를 것"; + const 조건 = Object.fromEntries([...conds].map(([n, f]) => [n, f.input.value.trim()])); + return { 할일: "연결", 갈래: "표", 표: one.키, 칸: cols.select.value, 조건 }; + }, + }; +}; + +/** 새 기호 줄 — 국문 이름 + 연결 고르기 · `read` 가 고침 `연결` 한 건을 줌 */ +export function draftRow(token: string, host: DefsHost): Draft { + const parts = symbolParts(token); + const name = field("국문 이름"); + const kind = createSelectField({ label: "연결", options: KINDS, value: "입력", size: 24 }); + const body = grid(); + const why = reasonBox(); + let read: Reader = () => "연결을 고를 것"; + const mount = (): void => { + const made = { 입력: inputKind, 상수: constKind }[kind.select.value as "입력" | "상수"]?.(); + const picked = + made ?? + { 요소: elementKind, 로직: logicKind, 표: tableKind }[ + kind.select.value as "요소" | "로직" | "표" + ](host); + body.replaceChildren(...picked.nodes); + read = picked.read; + }; + kind.select.addEventListener("change", mount); + mount(); + const head = el("div", { + className: "m01-eq__def-actions", + children: [ + el("span", { className: "ui-text ui-text--muted", text: `새 기호 ${token} — 연결을 지정` }), + ], + }); + const root = el("div", { + className: "ui-eq__def m01-eq__draft", + children: [head, grid(name.root, kind.root), body, why.root], + }); + root.dataset.eqDraft = token; + return { + root, + say: (text) => why.say(text ? [text] : []), + read: () => { + const made = read(); + if (typeof made === "string") return made; + const nameText = name.input.value.trim(); + return { ...made, ...parts, ...(nameText ? { 이름: nameText } : {}) }; + }, + }; +} diff --git a/M01_MasterData/logic/M01_Logic_Equation_Model.ts b/M01_MasterData/logic/M01_Logic_Equation_Model.ts index aaf2b9ace..7df79333f 100644 --- a/M01_MasterData/logic/M01_Logic_Equation_Model.ts +++ b/M01_MasterData/logic/M01_Logic_Equation_Model.ts @@ -8,7 +8,7 @@ import type { EqLine } from "@ui/ui_template_equation"; export interface EqEdit { - 할일: "식" | "더함" | "지움" | "변수" | "표시"; + 할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결"; [key: string]: unknown; } @@ -112,3 +112,12 @@ export function addEdit(form: VarForm, after?: string): EqEdit { } else if (form.기본값) out.기본값 = form.기본값; return out; } + +/** 서버 까닭 「… · 모르는 기호 「A」 「B」 — …」 → 새 기호 글 목록(`master_equation_edit._set_formula` 가 같은 글을 냄) */ +export function unknownOf(reasons: string[]): string[] { + for (const why of reasons) { + const hit = /모르는 기호((?:\s*「[^」]+」)+)/.exec(why); + if (hit?.[1]) return [...hit[1].matchAll(/「([^」]+)」/g)].map((m) => m[1] ?? ""); + } + return []; +} diff --git a/M02_MasterTemplete/M02_Sangeun_Equation.py b/M02_MasterTemplete/M02_Sangeun_Equation.py index 45d8de546..de76427d3 100644 --- a/M02_MasterTemplete/M02_Sangeun_Equation.py +++ b/M02_MasterTemplete/M02_Sangeun_Equation.py @@ -163,6 +163,11 @@ def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]: return out | ({"reason": lost[0]["까닭"]} if lost else {}) +def links(doc: Any, ask: dict) -> dict[str, Any]: + """새 기호 연결 후보(800-3) — M01 과 같은 후보 길(읽기만).""" + return _eqn().links(ask) + + def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]: """고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본 SK 금액), 후}.""" from M02_MasterTemplete import M02_Sangeun_Equation_Full as full diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 6ab140d92..e87d765db 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -251,6 +251,26 @@ def sangeun_equation( return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs) +class LinksBody(BaseModel): + 문서: dict[str, Any] | None = None + 갈래: str + 검색: str = "" + 분류: str = "인력" + 블록: int | None = None + + +@router.post("/layers/{layer}/sangeun/equation/links") +def sangeun_equation_links( + layer: Layer, + body: LinksBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만).""" + ask = {k: getattr(body, k) for k in ("갈래", "검색", "분류")} + return _call(_root(session, layer, owner), _sd(eqm.links), body.문서 or {}, ask) + + @router.post("/layers/{layer}/sangeun/equation/preview") def sangeun_equation_preview( layer: Layer, diff --git a/resources/master_data/scripts/master_equation.py b/resources/master_data/scripts/master_equation.py index 7514473a4..a050c0896 100644 --- a/resources/master_data/scripts/master_equation.py +++ b/resources/master_data/scripts/master_equation.py @@ -28,7 +28,9 @@ from pathlib import Path import master_auto as ma import master_cut as mcut +import master_equation_view as view import master_formula as mf +import master_material as mm from common_util.common_util_money import fmt @@ -195,10 +197,18 @@ def _raw(node) -> str: return me.raw_text(node) +def me_keyboard(ctx: _Ctx, node) -> str: + """조건 식 마디 → 키보드 한 줄 글(변수 = 기호) — 정의 영역 「조건 규격 = 폼타이규격」.""" + import master_equation_edit as me + + return me.keyboard_of(_raw(node), ctx.row) + + def _find(ctx: _Ctx, node, column: str) -> dict: """찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행.""" raw = _raw(("field", node, column)) more = {"식": raw, "칸": column} + more["조건식"] = {k: me_keyboard(ctx, c) for k, c in node[2]} try: ref = mf.fill(node[1], ctx.env) table_row = ctx.master.get(ref) @@ -451,6 +461,11 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: total = {k: cut.get(k) for k in mf.BUNDLE} if depth == 0: sub = {"소계": subtotals(row, cut, total)} + defs = ( + {"정의": view.definitions(out_lines, row, table(row, master), _unused(row))} + if depth == 0 + else {} + ) return _clean( { "열쇠": row.get("키"), @@ -461,10 +476,18 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: "변수표": table(row, master), "합계": total, **sub, + **defs, } ) +def _unused(row: dict): + """열쇠 → 어느 식에도 안 쓰이나(정의 영역 · 지울 수 있는 입력).""" + import master_equation_edit as me + + return lambda key: key != mm.REGION and not me.uses(row, key) + + def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict: """호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록.""" name = str(item.get("이름") or "") diff --git a/resources/master_data/scripts/master_equation_edit.py b/resources/master_data/scripts/master_equation_edit.py index 1c6c97771..2b35e6fe9 100644 --- a/resources/master_data/scripts/master_equation_edit.py +++ b/resources/master_data/scripts/master_equation_edit.py @@ -10,6 +10,7 @@ {할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록 {할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐 {할일: 표시, 열쇠, 표시: 0~6 | null} + {할일: 연결, 열쇠, 갈래: 입력|표|요소|로직|상수, …} — 새 기호의 연결 지정(`master_equation_link.py` 머리) 고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로. 키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로 (`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂). @@ -22,6 +23,7 @@ import re from decimal import Decimal, InvalidOperation import master_equation as meq +import master_equation_link as link import master_formula as mf KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지 @@ -227,12 +229,39 @@ def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]: return words(text, row) if words and text else (text, []) +def unknown(text: str, row: dict) -> list[str]: + """엔진 식 글 안 모르는 변수 글(변수 · 열쇠 · 예약어 · 함수 · 칸 · 인자 이름 · 요소 키 빼고).""" + known = set(_names(row)[0]) | _all_keys(row) | RESERVED + out, at = [], 0 + while at < len(text): + if text[at] in "'[": + end = text.find("'" if text[at] == "'" else "]", at + 1) + at = len(text) if end < 0 else end + 1 + continue + m = None if at and text[at - 1].isalnum() else _TOKEN.match(text, at) + if not m: + at += 1 + continue + word, before, rest = m.group(), text[:at].rstrip(), text[m.end() :].lstrip() + at = m.end() + arg = rest.startswith("=") and not rest.startswith("==") + skip = before.endswith(".") or rest.startswith("(") or arg + if not (skip or word in known or mf.mk.KEY.fullmatch(word) or word in out): + out.append(word) + return out + + def _set_formula(row: dict, ed: dict, why: list, spots: set, words=None) -> None: spot = str(ed.get("자리") or "") text, bad = _text_of(ed, row, words) if bad: why += [f"{spot} · {w}" for w in bad] return + if not isinstance(ed.get("나무"), dict) and (miss := unknown(text, row)): + why.append( + f"{spot} · 모르는 기호 「{'」 「'.join(miss)}」 — 변수 정의 영역에서 연결을 지정" + ) + return if not text: why.append(f"{spot} · 식이 빔") return @@ -383,6 +412,9 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str], _look(new, key, ed, why) elif job == "표시": _show(new, key, ed, why) + elif job == "연결": + add, bad = link.plan(master, ed) + _add(new, add, why, spots, words) if add else why.append(bad) else: why.append(f"모르는 할일 「{job}」") except (mf.FormulaError, KeyError, TypeError, ValueError) as e: diff --git a/resources/master_data/scripts/master_equation_link.py b/resources/master_data/scripts/master_equation_link.py new file mode 100644 index 000000000..ed578c84b --- /dev/null +++ b/resources/master_data/scripts/master_equation_link.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""수식 변수 연결 지정(PLAN 800-3 · 요구 660-5) — 새 기호가 무엇과 이어지는지 고르는 서버 몫. + +고침 `{할일: 연결, 열쇠, 갈래, 이름?, 기호?, 위?, 아래?, 뒤?, …}` 을 기존 `더함` 한 건으로 풂(`plan`): + 입력 {기본값?, 단위?} → 입력 변수(옵션 칸에 생김) + 표 {표, 칸, 조건: {열: 식글}} → 중간 줄 「찾기(표, 열=식…).칸」 + 요소 {요소: 키} → 중간 줄 「키」(인력 · 재료 · 기계 단가) + 로직 {로직: 키, 인자?: {칸: 식}} → 중간 줄 「로직(키, 칸=식…)」 + 상수 {값: 수} → 중간 줄 수 +후보 목록 = `candidates`(표 · 요소 · 로직) — 읽기만 · 값 · 열쇠는 이름 옆 보관용(화면은 이름만 보임). +셈 · 검사는 `master_equation_edit.apply` 가 한 벌로(여기는 글만 짓고 막을 것만 막음). +""" + +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation + +LIMIT = 30 +KINDS = ("입력", "표", "요소", "로직", "상수") +ELEMS = {"인력": ("LB",), "재료": ("MT", "MO"), "기계": ("EQ",)} +TABLES = ("QC", "QF") +_KEY = re.compile(r"[A-Z]{2}\d{6}") +_COL = re.compile(r"^[^\W\d][\w]*$") + + +def _q(word: str) -> str: + return word if _COL.match(word) else f"'{word}'" + + +def _row(master, key: str): + try: + return master.get(key) + except Exception: # noqa: BLE001 — 마스터 없는 열쇠 = 어떤 오류든 「없음」 + return None + + +def plan(master, ed: dict) -> tuple[dict | None, str]: + """연결 고침 → (더함 고침, 까닭) — 까닭이 있으면 더함 없음.""" + kind = ed.get("갈래") + add = { + "할일": "더함", + **{k: ed[k] for k in ("열쇠", "이름", "기호", "위", "아래", "단위", "뒤") if k in ed}, + } + if kind not in KINDS: + return None, f"연결 갈래 「{kind}」 — {' · '.join(KINDS)}" + if kind == "입력": + add |= {"갈래": "입력"} | ( + {"기본값": ed["기본값"]} if ed.get("기본값") not in (None, "") else {} + ) + return add, "" + add["갈래"] = "중간" + if kind == "상수": + try: + add["식"] = format(Decimal(str(ed.get("값"))).normalize(), "f") + except (InvalidOperation, ValueError): + return None, "상수는 수로 적어야 함" + elif kind == "요소": + key = str(ed.get("요소") or "") + if ( + not _KEY.fullmatch(key) + or key[:2] not in sum(ELEMS.values(), ()) + or not _row(master, key) + ): + return None, "요소를 골라야 함(인력 · 재료 · 기계)" + add["식"] = key + elif kind == "로직": + key = str(ed.get("로직") or "") + if not _KEY.fullmatch(key) or not key.startswith("G") or not _row(master, key): + return None, "부를 로직을 골라야 함" + args = "".join( + f", {_q(k)}={v}" for k, v in (ed.get("인자") or {}).items() if str(v).strip() + ) + add["식"] = f"로직({key}{args})" + else: + key, col = str(ed.get("표") or ""), str(ed.get("칸") or "") + table = _row(master, key) if _KEY.fullmatch(key) else None + if not table or "조건" not in table or col not in (table.get("값칸") or {}): + return None, "표와 칸을 골라야 함" + given = ed.get("조건") or {} + miss = [c for c in table["조건"] if not str(given.get(c) or "").strip()] + if miss: + return None, f"표 조건을 채워야 함 — {' · '.join(miss)}" + cond = "".join(f", {_q(c)}={given[c]}" for c in table["조건"]) + add["식"] = f"찾기({key}{cond}).{_q(col)}" + return add, "" + + +def _hit(text: object, words: list[str]) -> bool: + low = str(text or "").lower() + return all(w in low for w in words) + + +def candidates(master, ask: dict) -> dict: + """후보 {갈래: 표 | 요소 | 로직, 검색, 분류?(요소)} → {후보: [{키, 이름, …}]} — 최대 `LIMIT` 건.""" + kind, words = ask.get("갈래"), str(ask.get("검색") or "").lower().split() + out: list[dict] = [] + if kind == "표": + pools = [(k, r) for p in TABLES for k, r in master.index.get(p, {}).items() if "조건" in r] + for key, r in pools: + if _hit(r.get("이름"), words): + out.append( + { + "키": key, + "이름": r.get("이름"), + "조건": list(r["조건"]), + "칸": list(r.get("값칸") or {}), + } + ) + elif kind == "요소": + for p in ELEMS.get(str(ask.get("분류") or "인력"), ()): + for key, r in master.index.get(p, {}).items(): + if (words or p != "MT") and _hit(f"{r.get('이름')} {r.get('규격') or ''}", words): + out.append({"키": key, "이름": r.get("이름"), "규격": r.get("규격") or ""}) + elif kind == "로직": + for key, r in master.index.get("GC", {}).items(): + if words and _hit(r.get("이름"), words): + out.append( + { + "키": key, + "이름": r.get("이름"), + "입력": [s["이름"] for s in r.get("입력") or []], + } + ) + return {"후보": out[:LIMIT], "더있음": len(out) > LIMIT} diff --git a/resources/master_data/scripts/master_equation_view.py b/resources/master_data/scripts/master_equation_view.py new file mode 100644 index 000000000..8cbdc64cc --- /dev/null +++ b/resources/master_data/scripts/master_equation_view.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""수식 변수 정의 영역(PLAN 800-2 · 요구 660-2 · 3 · 6) — 수식 나무 → 오른쪽 「변수 정의」 목록. + +줄 구분 없이 한 목록 · 같은 변수는 한 번만(겹침 합침) · 기호 : 국문 이름 — 연결(입력 · 표 찾기 · +요소 · 로직 · 계산을 사람 말로) · 값 · 단위 · DB 열쇠 글은 담지 않음. +지금 고른 옵션 갈래만 — 만약의 탄 갈래 표지 `걸림`(1 참 · 2 거짓 · 0 모름 = 둘 다)을 그대로 씀(새 판정 없음) · +`안씀` = 어느 식에도 안 쓰이는 입력(지울 수 있음) · 쓰는 변수 지움 막기는 `master_equation_edit._drop`. +""" + +from __future__ import annotations + +CAT = {"LB": "인력", "EQ": "기계", "MT": "재료", "MO": "재료"} +WHAT = { + "입력": "입력", + "중간": "계산", + "결과": "계산 결과", + "찾기": "표 찾기", + "요소": "요소", + "블록": "로직 부르기", +} + + +def visible(node: dict | None, out: list[dict]) -> list[dict]: + """나무에서 보이는(탄 갈래) 변수 · 블록 마디를 차례대로.""" + if not isinstance(node, dict): + return out + kind = node.get("꼴") + if kind in ("변수", "블록"): + out.append(node) + elif kind == "조건": + visible(node.get("조건"), out) + for key, at in (("참", 1), ("거짓", 2)): + if node.get("걸림") in (0, None, at): + visible(node.get(key), out) + else: + for key in ("좌", "우", "안", "위", "아래"): + visible(node.get(key), out) + for arg in node.get("인자") or []: + visible(arg, out) + return out + + +def _link(node: dict, spec: dict | None, leaf: dict | None) -> str: + """연결 설명 — 사람 말(열쇠 · 값 · 단위 없음).""" + kind = node.get("갈래") or ("블록" if node.get("꼴") == "블록" else "") + if kind == "입력": + return "입력 — " + ("옵션에서 고름" if (spec or {}).get("고르기") else "직접 적음") + if kind == "중간" and leaf is not None and leaf is not node: + return _link(leaf, None, None) + if kind == "상수": + return f"상수 — {node.get('글')}" + if kind == "찾기": + cond = " · ".join(f"{k} = {v}" for k, v in (node.get("조건식") or {}).items()) + head = f"표 찾기 — {node.get('표이름') or '표'}" + return head + (f" · 조건 {cond}" if cond else "") + f" · 칸 {node.get('칸') or ''}".rstrip() + if kind == "요소": + pick = node.get("고르기") + if isinstance(pick, dict): + return "요소 · 재료 고름 — " + " · ".join(str(v) for v in pick.values() if v) + return f"요소 · {CAT.get(str(node.get('식') or '')[:2], '단가')} — {node.get('이름') or ''}" + if kind == "블록": + return f"로직 부르기 — {node.get('이름') or ''}" + return WHAT.get(kind, "계산") + (" — 수식 영역의 식" if kind in ("중간", "결과") else "") + + +def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[dict]: + """보이는 식의 변수 정의 목록 — `rows` = 변수 표 · `unused(열쇠)` = 어느 식에도 안 쓰임.""" + specs = {str(s.get("이름")): s for k in ("입력", "중간") for s in row.get(k) or []} + table = {v["열쇠"]: v for v in rows} + got: dict[str, dict] = {} + leaf: dict[str, dict] = {} + for line in lines: + head, tree = line.get("결과"), line.get("이름식") + if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict): + if tree.get("꼴") == "수": # 상수 — 연결 설명에 그 수 + leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")} + elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식 + leaf[str(line.get("열쇠"))] = tree + found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible( + tree, [] + ) + for node in found: + key = str(node.get("열쇠") or "") + if key and key not in got and node.get("갈래") in (*WHAT, "합", None): + got[key] = node + out: dict[tuple, dict] = {} # 같은 기호 글 = 같은 정의(겹침 합침) — 연결은 모아 보임 + for key, node in got.items(): + mine = table.get(key) + look = mine or node + kind = node.get("갈래") or "블록" + if kind == "합": + kind = "결과" if key == "결과" else "중간" + if key.startswith("줄."): # 위 호표 줄 금액 부름 + name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}" + else: + link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key)) + if kind == "블록" and not leaf.get(key): + link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함 + name = (look.get("이름") or key) if mine else look.get("이름") or "" + name = node.get("칸") or name if kind == "찾기" else name + one = { + "열쇠": key if mine else "", + "기호": (look.get("기호") or name) if not key.startswith("줄.") else name, + "위": look.get("위") or "", + "아래": look.get("아래") or "", + "이름": name, + "갈래": kind, + "연결": [link], + "고침": bool(mine), + } + same = out.setdefault((one["기호"], one["위"], one["아래"]), one) + if same is not one: + same["연결"] += [x for x in one["연결"] if x not in same["연결"]] + if one["열쇠"] and not same["열쇠"]: + same |= {k: one[k] for k in ("열쇠", "이름", "갈래", "고침")} + out = list(out.values()) + seen = {d["열쇠"] for d in out} + for v in rows: # 어느 식에도 안 쓰이는 입력 — 지울 수 있게 + if v["갈래"] == "입력" and v["열쇠"] not in seen and unused(v["열쇠"]): + out.append( + { + "열쇠": v["열쇠"], + "기호": v["기호"], + "위": v["위"], + "아래": v["아래"], + "이름": v["이름"], + "갈래": "입력", + "연결": ["입력 — 쓰는 식 없음"], + "고침": True, + "안씀": True, + } + ) + return out diff --git a/resources/tester/test_796_1_formula_model.py b/resources/tester/test_796_1_formula_model.py index 03615ce37..99d84038a 100644 --- a/resources/tester/test_796_1_formula_model.py +++ b/resources/tester/test_796_1_formula_model.py @@ -296,7 +296,7 @@ def test_이름_기호_첨자_바꿈_전파(): def test_검사_까닭(): cases = { - "없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 이름"), + "없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 기호"), "괄호": ("중간:환산거리", "(환산계수 × L", "없음"), "아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"), "순환": ("중간:무게", "무게 + 1", "자기 자신"), diff --git a/resources/tester/test_796_2_equation_screen.py b/resources/tester/test_796_2_equation_screen.py index 27d08499d..3267a172b 100644 --- a/resources/tester/test_796_2_equation_screen.py +++ b/resources/tester/test_796_2_equation_screen.py @@ -32,7 +32,6 @@ def test_부품_상자만_날_단추_입력_없음(): # 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것 page = _text(PAGE[0]) + _text(PAGE[1]) for part in ( - "createIconButton", "createButton", "createToolbar", "createFormulaCell", @@ -84,15 +83,17 @@ def test_수식_탭이_첫_탭_단가산출_상세는_그대로(): assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음 -def test_아이콘_줄은_고른_줄에만_고침은_관리자만(): +def test_식_바로_고침은_관리자만(): + # 800-3 — 줄을 눌러 여는 아래 편집 · 아이콘 줄을 걷고 식 칸을 그 자리에서 고침 line = _text(PARTS[1]) - assert "o.selected === id ? o.slot?.(line, id) : null" in line # 고른 줄에만 식 칸 · 아이콘 줄 + assert "o.selected" not in line and "o.slot" not in line and "onSelect" not in line + assert "o.editing === id && o.editor" in line # 이름 식 칸 자리에 식 칸 page = _text(PAGE[0]) - for icon in ('"frac"', '"paren"', '"sub"', '"sup"', '"percent"'): - assert icon in page, icon # 분수 · 괄호 · 첨자 · % + for icon in ('"frac"', '"paren"', '"sub"', '"sup"'): + assert icon not in page, icon # 아이콘 줄 걷음 assert 'label: t("Add")' in page # 변수 더함 assert "opt.admin && !opt.blocked()" in page # 관리자 + 시트 쪽 저장 안 한 고침 없을 때만 - assert "onSelect: edit" in page and "onVar: edit" in page and "slot: edit" in page + assert "onEdit: edit" in page and "editor: edit" in page assert "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page diff --git a/resources/tester/test_800_2_equation_layout.py b/resources/tester/test_800_2_equation_layout.py new file mode 100644 index 000000000..0d23aa77c --- /dev/null +++ b/resources/tester/test_800_2_equation_layout.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""PLAN 800-2 · 800-3 — 수식 탭 변수 정의 영역 · 바로 고침 · 새 기호 연결 (서버 몫 · 임시 쓰기 없음).""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")] + +from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402 +from M01_MasterData import M01_MasterData_Store as store # noqa: E402 + +KEY = "GC000211" +OFF = {"폼타이": "아님", "측압": "3t/㎡"} +ON = {"폼타이": "사용", "측압": "4t/㎡"} + + +def _defs(inputs): + return eq.read(KEY, inputs)["수식"]["정의"] + + +def _text(defs): + return json.dumps(defs, ensure_ascii=False) + + +def test_defs_merge_and_clean(): + defs = _defs(OFF) + signs = [(d["기호"], d["위"], d["아래"]) for d in defs] + assert len(signs) == len(set(signs)), "같은 변수는 한 번만" + assert not re.search(r"[A-Z]{2}\d{6}", _text(defs)), "DB 열쇠 글 0" + assert all(set(d) <= {"열쇠", "기호", "위", "아래", "이름", "갈래", "연결", "고침", "안씀"} for d in defs) + by = {d["기호"]: d for d in defs} + assert len(by["보통인부"]["연결"]) >= 3 # 중간 줄 + 표 둘 — 한 줄로 합침 + assert by["사용횟수"]["연결"] == ["입력 — 옵션에서 고름"] + + +def test_unpicked_branches_hidden(): + off, on = _text(_defs(OFF)), _text(_defs(ON)) + for col in ("4t/㎡", "5t/㎡", "6t/㎡", "폼타이 소요량"): + assert col not in off + assert "칸 4t/㎡" in on and "칸 3t/㎡" not in on and "칸 5t/㎡" not in on + assert "측압" in on and "측압" not in off # 폼타이 아님이면 측압 갈래도 안 탐 + + +def test_live_edit_preview_changes_total(): + spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2"} + got = eq.preview(KEY, [spot], OFF) + assert got["되나"] and got["후"]["계"] > got["전"]["계"] + + +def test_unknown_symbol_asks_link_then_links(): + spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2 + Z"} + got = eq.preview(KEY, [spot], OFF) + assert not got["되나"] and "모르는 기호 「Z」" in got["까닭"][0] + for link in ( + {"갈래": "상수", "값": "0"}, + {"갈래": "입력", "기본값": "0"}, + {"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {"유형": "유형"}}, + {"갈래": "요소", "요소": "LB000007"}, + ): + add = {"할일": "연결", "열쇠": "Z", "이름": "새값", **link} + got = eq.preview(KEY, [add, spot], OFF) + assert got["되나"], (link, got["까닭"]) + assert any(d["기호"] == "Z" for d in got["수식"]["정의"]) + + +def test_link_bad_input_blocked(): + bad = [ + {"갈래": "표", "표": "QC000431", "칸": "없는칸", "조건": {"유형": "유형"}}, + {"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {}}, + {"갈래": "요소", "요소": "GC000211"}, + {"갈래": "상수", "값": "abc"}, + ] + for link in bad: + got = eq.preview(KEY, [{"할일": "연결", "열쇠": "Z", **link}], OFF) + assert not got["되나"], link + + +def test_drop_guard_and_unused_input(): + got = eq.preview(KEY, [{"할일": "지움", "열쇠": "사용횟수"}], OFF) + assert not got["되나"] and "쓰는 곳" in got["까닭"][0] + add = {"할일": "연결", "열쇠": "Y", "갈래": "입력"} + got = eq.preview(KEY, [add], OFF) + unused = [d for d in got["수식"]["정의"] if d["기호"] == "Y"] + assert unused and unused[0].get("안씀") + assert eq.preview(KEY, [add, {"할일": "지움", "열쇠": "Y"}], OFF)["되나"] + + +def test_candidates(): + whole = store.loaded()[1] + got = eq.links({"갈래": "표", "검색": "합판거푸집 폼타이", "분류": ""}) + assert got["후보"] and "조건" in got["후보"][0] + assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"] + assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"] + assert whole is not None + + +def test_groups_add_up_to_total(): + """800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경.""" + for key in ("GC001148", "GC000211"): + eqn = eq.read(key, {})["수식"] + sub, total = eqn["소계"], eqn["합계"] + for k in ("노무비", "재료비", "경비"): + got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k]) + assert abs(got - float(total[k])) < 1e-6, (key, k) + assert all( + abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6 + for r in sub["줄"] + ) + + +def test_screen_source_groups_and_inline_edit(): + ui = ROOT / "ui_template" + sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8") + line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8") + page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") + assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음 + assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래 + assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음 + assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음 diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index dd7ef3587..eafb95fb3 100644 --- a/ui_template/ui_template_equation.css +++ b/ui_template/ui_template_equation.css @@ -158,37 +158,17 @@ font-weight: var(--font-weight-medium); } -/* ── 줄 ── */ +/* ── 줄(800-2 — 구분선 · 번호 · 풀이 없음 · 식이 차례로 이어짐) ── */ .ui-eq__line { display: flex; flex-direction: column; gap: var(--spacing-4); - padding: var(--spacing-8); - border-bottom: 1px solid var(--color-border); + padding: var(--spacing-4) 0; min-width: 0; } -.ui-eq__line[data-selected] { - box-shadow: inset 3px 0 0 var(--color-accent); - background: var(--color-accent-soft); -} - -.ui-eq__line.is-pick > .ui-eq__main { - cursor: pointer; -} - .ui-eq__main { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem); - gap: var(--spacing-12); - align-items: start; -} - -.ui-eq__step { - align-self: flex-start; - min-width: 1.4em; - color: var(--color-text-muted); - font-size: var(--text-caption); + min-width: 0; } /* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */ @@ -226,51 +206,24 @@ font-size: var(--text-caption); } -/* 변수 풀이 */ -.ui-eq__solve { - display: flex; - flex-direction: column; - gap: 2px; +/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */ +.ui-eq__name.is-edit { + cursor: text; +} + +.ui-eq__name.is-edit:hover { + border-color: var(--color-accent); +} + +.ui-eq__name.is-open { + flex: 1 1 24rem; + align-items: flex-start; + border-color: var(--color-accent); +} + +.ui-eq__name.is-open > * { + flex: 1 1 auto; min-width: 0; - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - -.ui-eq__solve-row { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 0 var(--spacing-4); - min-width: 0; -} - -.ui-eq__solve-row.is-pick { - cursor: pointer; -} - -.ui-eq__solve-row.is-pick:hover { - background: var(--color-accent-soft); -} - -.ui-eq__solve-name, -.ui-eq__solve-val { - max-width: 12em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ui-eq__solve-val { - color: var(--color-text-body); - font-variant-numeric: tabular-nums; -} - -.ui-eq__solve-src { - color: var(--color-text-muted); - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } /* 하위 로직 블록 — 접힘 */ @@ -300,11 +253,6 @@ color: var(--color-text-secondary); } -.ui-eq__block-key { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - .ui-eq__block-body { margin-top: var(--spacing-4); } @@ -313,12 +261,6 @@ padding: var(--spacing-4); } -@container (max-width: 640px) { - .ui-eq__main { - grid-template-columns: minmax(0, 1fr); - } -} - :is(.ui-eq__block-body, .ui-eq__bad)[hidden] { display: none !important; } @@ -411,3 +353,125 @@ .ui-eq__sum-row[data-eq-sum-kind="합계"] { font-weight: var(--font-weight-semibold); } + +/* ── 변수 정의 영역(800-2) — 오른쪽 한 덩어리 ── */ +.ui-eq__defs { + position: sticky; + top: var(--spacing-8); + align-self: start; + max-height: calc(100vh - var(--spacing-24)); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; + padding: var(--spacing-8); + border-left: 1px solid var(--color-border); + font-size: var(--text-body-sm); + color: var(--color-text-secondary); +} + +.ui-eq__defs-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__def { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4); + border-radius: var(--radius-sm); + min-width: 0; +} + +.ui-eq__def-top { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ui-eq__def.is-pick > .ui-eq__def-top { + cursor: pointer; +} + +.ui-eq__def.is-pick > .ui-eq__def-top:hover, +.ui-eq__def[data-flash], +.ui-eq__def[data-editing] { + background: var(--color-accent-soft); +} + +.ui-eq__def-main { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 var(--spacing-4); + color: var(--color-text-body); +} + +.ui-eq__def-name { + overflow-wrap: anywhere; +} + +.ui-eq__def-tag { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__def-link { + color: var(--color-text-muted); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.ui-eq__def-empty { + color: var(--color-text-muted); +} + +/* ── 기준 단가산출별 묶음(800-4 · 요구 661) — 가. 이름 → 노 · 재 · 경 → 소 계 ── */ +.ui-eq__grp { + font-size: var(--text-body-sm); + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8) 0 var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.ui-eq__grp-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: 0 var(--spacing-8); + font-weight: var(--font-weight-semibold); +} + +.ui-eq__grp-line { + display: flex; + align-items: baseline; + gap: var(--spacing-4); + padding: 0 var(--spacing-8) 0 var(--spacing-24); + font-variant-numeric: tabular-nums; +} + +.ui-eq__grp-key { + min-width: 4.5em; + color: var(--color-text-secondary); +} + +.ui-eq__grp-total .ui-eq__sum-cell::before { + content: attr(data-eq-sum-col) " "; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__grp-total { + border-bottom: 0; + font-weight: var(--font-weight-medium); +} diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts index e06283f2e..b438eef97 100644 --- a/ui_template/ui_template_equation.ts +++ b/ui_template/ui_template_equation.ts @@ -29,6 +29,7 @@ export interface EqNode { 표?: string; 표이름?: string; 행?: Record; + 조건식?: Record; 좌?: EqNode; 우?: EqNode; 안?: EqNode; @@ -89,6 +90,24 @@ export interface EqTree { 소계?: { 줄: EqSumRow[]; 끝수: Record; 끝수까닭?: string }; /** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */ 답까닭?: string; + /** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */ + 정의?: EqDef[]; +} + +/** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */ +export interface EqDef { + /** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */ + 열쇠: string; + 기호: string; + 위: string; + 아래: string; + 이름: string; + 갈래: string; + 연결: string[]; + /** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */ + 고침: boolean; + /** 어느 식에도 안 쓰이는 입력(지울 수 있음) */ + 안씀?: boolean; } export interface EqVarRow { diff --git a/ui_template/ui_template_equation_defs.ts b/ui_template/ui_template_equation_defs.ts new file mode 100644 index 000000000..6c5acb736 --- /dev/null +++ b/ui_template/ui_template_equation_defs.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * ui_template_equation_defs.ts + * 부품 「수식」 넷째 파일(800-2 · 요구 660-2 · 3 · 6) — 오른쪽 「변수 정의 영역」. + * 한 덩어리 목록 · 같은 기호는 한 줄(서버가 합침) · 줄 = 기호 : 국문 이름 — 연결 설명(입력 · 표 찾기 · 요소 · 로직 · 계산을 사람 말로) + * 값 · 단위 · DB 열쇠 글은 안 그림(값은 왼쪽 값 식에 있음) · 고른 옵션 갈래의 변수만(서버 `정의`) + * 상태 = 고침 열린 줄 `data-editing`(줄 아래 `editor` 칸) · 연결 안 한 새 기호 줄 `data-draft`(`drafts` 요소 — 부르는 쪽이 만듦) · 눌러 보인 줄 `data-flash` + * 쓰는 법 = `createEqDefs(defs, { editing, onEdit, editor, drafts, head })` → 요소 · `flashEqDef(root, node)` = 식의 변수를 눌렀을 때 그 정의 줄로 이동 + * ========================================================================== */ + +import { symbolOf, type EqDef, type EqNode } from "./ui_template_equation"; + +export interface EqDefsOptions { + /** 고침이 열린 정의의 기호 글(`defSign`) */ + editing?: string | null; + /** 정의 줄을 누름(없으면 고칠 수 없음 — 고침 가능한 줄만 눌림) */ + onEdit?: (def: EqDef) => void; + /** 고침이 열린 줄 아래 칸 */ + editor?: (def: EqDef) => HTMLElement; + /** 연결을 지정할 새 기호 줄들 */ + drafts?: HTMLElement[]; + /** 머리 오른쪽 단추 자리 */ + head?: HTMLElement[]; +} + +const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { + const out = document.createElement(tag); + out.className = className; + if (text) out.textContent = text; + return out; +}; + +/** 정의의 기호 글(기호 · 아래 · 위) — 줄 이름표 · 식의 변수와 맞대는 열쇠 */ +export const defSign = (d: Pick): string => + `${d.기호}${d.아래 ? `_${d.아래}` : ""}${d.위 ? `^${d.위}` : ""}`; + +/** 식 변수 마디의 기호 글 — `defSign` 과 같은 꼴 */ +export const nodeSign = (n: Pick): string => + defSign({ + 기호: n.기호 || n.열쇠 || "", + 위: typeof n.위 === "string" ? n.위 : "", + 아래: typeof n.아래 === "string" ? n.아래 : "", + }); + +function defRow(d: EqDef, o: EqDefsOptions): HTMLElement { + const sign = defSign(d); + const row = node("div", "ui-eq__def"); + row.dataset.eqDef = sign; + row.dataset.kind = d.갈래; + const main = node("div", "ui-eq__def-main"); + main.append(symbolOf({ 열쇠: d.열쇠, 기호: d.기호, 위: d.위, 아래: d.아래 })); + if (d.이름 && d.이름 !== d.기호) + main.append(node("span", "ui-eq__op", ":"), node("span", "ui-eq__def-name", d.이름)); + if (d.안씀) main.append(node("span", "ui-eq__def-tag", "쓰는 식 없음")); + const links = node("div", "ui-eq__def-links"); + for (const text of d.연결) links.append(node("div", "ui-eq__def-link", text)); + const top = node("div", "ui-eq__def-top", ""); + top.append(main, links); + if (o.onEdit && d.고침) { + row.classList.add("is-pick"); + top.title = "눌러서 이름 · 기호 · 첨자 고침 · 지움"; + top.addEventListener("click", () => o.onEdit?.(d)); + } + row.append(top); + if (o.editing === sign && o.editor) { + row.dataset.editing = "1"; + row.append(o.editor(d)); + } + return row; +} + +export function createEqDefs(defs: EqDef[], o: EqDefsOptions = {}): HTMLElement { + const root = node("div", "ui-eq__defs"); + root.dataset.eqDefs = ""; + const head = node("div", "ui-eq__defs-head"); + head.append(node("span", "ui-eq__defs-title", "변수 정의"), ...(o.head ?? [])); + root.append(head); + for (const draft of o.drafts ?? []) root.append(draft); // 새 기호는 맨 위 — 식을 고치는 곳 옆에서 바로 보이게 + for (const d of defs) root.append(defRow(d, o)); + if (!defs.length && !o.drafts?.length) root.append(node("div", "ui-eq__def-empty", "—")); + return root; +} + +/** 식의 변수를 눌렀을 때 — 그 정의 줄로 이동 · 잠깐 표시 */ +export function flashEqDef(root: HTMLElement, n: EqNode): void { + const sign = nodeSign(n); + const row = [...root.querySelectorAll("[data-eq-def]")].find( + (r) => r.dataset.eqDef === sign, + ); + if (!row) return; + row.scrollIntoView({ block: "nearest", behavior: "smooth" }); + row.dataset.flash = "1"; + window.setTimeout(() => delete row.dataset.flash, 1200); +} diff --git a/ui_template/ui_template_equation_line.ts b/ui_template/ui_template_equation_line.ts index dc5767e7b..2dec7bf36 100644 --- a/ui_template/ui_template_equation_line.ts +++ b/ui_template/ui_template_equation_line.ts @@ -1,13 +1,13 @@ /* ============================================================================= * ui_template_equation_line.ts - * 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`. - * 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처) + * 부품 「수식」 둘째 파일(796-2 · 800-2) — 수식 영역 줄 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`. + * 줄 = 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 줄 번호 · 구분선 · 줄마다 풀이 없음(변수 정의 = `ui_template_equation_defs.ts`) * 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글) - * 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음) - * 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22 - * 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open` - * 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })` - * 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) + * 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음) + * 크기 = 글 `--text-body` · 접힘 단추 22 + * 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open` + * 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })` + * 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음) * ========================================================================== */ import { createBadge } from "./ui_template_badge"; @@ -27,18 +27,18 @@ export interface EqLineOptions { path: string; /** 펼친 블록 이름표 모음 — 다시 그려도 유지 */ open: Set; - /** 고른 줄 이름표(`lineId`) */ - selected?: string | null; - /** 줄을 누름(없으면 안 눌림) */ - onSelect?: (line: EqLine, id: string) => void; /** 변수 누름(없으면 눌리지 않음) */ onVar?: (node: EqNode) => void; - /** 고른 줄 아래에 열 칸(식 칸 · 아이콘 줄) */ - slot?: (line: EqLine, id: string) => HTMLElement | null; + /** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */ + editing?: string | null; + /** 이름 식 칸을 누름(없으면 고칠 수 없음) */ + onEdit?: (line: EqLine, id: string) => void; + /** 고치는 줄의 식 칸 */ + editor?: (line: EqLine, id: string) => HTMLElement; + /** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */ + after?: (line: EqLine) => HTMLElement | null; /** 결과 줄 값 뒤 단위 */ resultUnit?: string; - /** 줄 번호 1 부터 보임 */ - numbered?: boolean; } const BUNDLE: [string, string][] = [ @@ -67,52 +67,6 @@ export const lineId = (line: EqLine): string => ? `${line.갈래}:${line.차례 ?? 0}` : `${line.갈래}:${line.열쇠}`; -/** 식 안 변수(찾기 · 입력 · 중간 · 요소) — 열쇠마다 하나 · 블록 속은 안 들어감 */ -export function varsOf(node: EqNode | undefined, out = new Map()): EqNode[] { - if (!node) return []; - if (node.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(node.갈래 ?? "")) { - if (!out.has(node.열쇠 ?? "")) out.set(node.열쇠 ?? "", node); - } - for (const k of ["좌", "우", "안", "조건", "참", "거짓"] as const) varsOf(node[k], out); - for (const k of ["위", "아래"] as const) { - const child = node[k]; - if (typeof child === "object") varsOf(child, out); - } - (node.인자 ?? []).forEach((a) => varsOf(a, out)); - return [...out.values()]; -} - -const SOURCE: Record = { 입력: "입력", 중간: "중간 줄", 요소: "요소" }; - -function solveRow(node: EqNode, o: EqLineOptions): HTMLElement { - const value = eqText(node.값); - const where = - node.갈래 === "찾기" - ? [node.표, ...Object.entries(node.행 ?? {}).map(([k, v]) => `${k}=${String(v)}`)] - .filter(Boolean) - .join(" · ") - : (SOURCE[node.갈래 ?? ""] ?? ""); - const name = span("ui-eq__solve-name", node.이름 || node.열쇠 || ""); - name.title = name.textContent ?? ""; - const val = span("ui-eq__solve-val", value ? `${value}${node.단위 ? ` ${node.단위}` : ""}` : "—"); - val.title = val.textContent ?? ""; - const row = div( - "ui-eq__solve-row", - symbolOf(node), - span("ui-eq__op", ":"), - name, - span("ui-eq__op", "="), - val, - ...(where ? [span("ui-eq__solve-src", where)] : []), - ); - row.dataset.eqSolve = node.열쇠 ?? ""; - if (o.onVar && node.갈래 !== "찾기" && node.갈래 !== "요소") { - row.classList.add("is-pick"); - row.addEventListener("click", () => o.onVar?.(node)); - } - return row; -} - function bundle(node: EqNode): HTMLElement { const box = span("ui-eq__bundle"); const got = node.묶음; @@ -149,7 +103,6 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { path: id, open: o.open, resultUnit: node.결과단위 ?? node.나무.결과단위, - numbered: false, }), ); } else o.open.delete(id); @@ -157,10 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { body.hidden = !on; }; body.hidden = true; - box.append( - div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")), - body, - ); + box.append(div("ui-eq__block-row", head, bundle(node)), body); if (o.open.has(id)) toggle(true); return box; } @@ -183,7 +133,7 @@ function markOf(line: EqLine): HTMLElement | null { } /** 줄 하나 */ -export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement { +export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement { const id = lineId(line); const root = div("ui-eq__line"); root.dataset.eqLine = id; @@ -201,7 +151,6 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM }, }; const body = div("ui-eq__body"); - if (o.numbered !== false) body.append(span("ui-eq__step", String(index + 1))); const head = line.결과; if (head) body.append( @@ -225,10 +174,19 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM line.글 || "—", ...(resUnit ? [span("ui-eq__unit", resUnit)] : []), ); - let vars: EqNode[] = []; if (line.이름식) { - vars = varsOf(line.이름식); - const name = span("ui-eq__box ui-eq__name", renderEq(line.이름식, hand)); + const name = span("ui-eq__box ui-eq__name"); + if (o.editing === id && o.editor) { + name.classList.add("is-open"); + name.append(o.editor(line, id)); + } else { + name.append(renderEq(line.이름식, hand)); + if (o.onEdit) { + name.classList.add("is-edit"); + name.title = "눌러서 바로 고침"; + name.addEventListener("click", () => o.onEdit?.(line, id)); + } + } body.append(span("ui-eq__op", "="), name); const same = plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식); @@ -245,21 +203,14 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM const mark = markOf(line); if (mark) body.append(mark); if (line.뺌) root.dataset.removed = "1"; - const solve = div("ui-eq__solve", ...vars.map((v) => solveRow(v, o))); - const main = div("ui-eq__main", body, solve); - if (o.onSelect) { - root.classList.add("is-pick"); - main.addEventListener("click", () => o.onSelect?.(line, id)); - } - if (o.selected === id) root.dataset.selected = "1"; - root.append(main); + root.append(div("ui-eq__main", body)); blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o))); - const slot = o.selected === id ? o.slot?.(line, id) : null; - if (slot) root.append(slot); + const extra = o.after?.(line); + if (extra) root.append(extra); return root; } /** 여러 줄 위에서 아래로 이음 */ export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement { - return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o))); + return div("ui-eq", ...lines.map((l) => createEqLine(l, o))); } diff --git a/ui_template/ui_template_equation_sum.ts b/ui_template/ui_template_equation_sum.ts index cadeab3e4..eab9950c9 100644 --- a/ui_template/ui_template_equation_sum.ts +++ b/ui_template/ui_template_equation_sum.ts @@ -1,10 +1,12 @@ /* ============================================================================= * ui_template_equation_sum.ts - * 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). - * 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합) - * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 - * M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 - * 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 · + * 부품 「수식」 셋째 파일(799-1 · 796-2c · 800-4 · 요구 655-1 · 661 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). + * 묶음 = 기준 단가산출마다(하위 로직 블록 · 자기 자원 · 덧줄) 「가. 이름」 머리 → 노무비 · 재료비 · 경비 줄 → 소 계(노 · 재 · 경 · 계 칸) + * 맨 끝 「소 계」 묶음 = 묶음 소계를 더한 식(노무비 : 2,299.4 + 5,162 = 7,461.4) → 끝수(합계 − 묶음 합 · 0 이면 줄 없음) → 합계(엔진 비목 합) + * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 소계가 없으면(결과 로직) 아무것도 안 그림 + * M02 SK 묶음 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 + * 자리 = 하위 로직 블록 묶음은 그 블록 식 줄 바로 아래(`inline` · 줄 열쇠 = 블록 이름 · `createEqChain` 의 `after`) · 나머지(자기 자원 · 덧줄 · 더한 기초) · 맨 끝 소 계 · 끝수 · 합계는 `tail` + * 쓰는 법 = `createEqSum(tree, open?, hooks?, lineKeys?)` → { inline, tail } | null — `lineKeys` = 수식 영역 호표 줄 열쇠들 · `open` = 펼친 블록 이름표 모음 · * `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기) * ========================================================================== */ @@ -14,8 +16,14 @@ import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_templa import { createEqChain } from "./ui_template_equation_line"; const COLS = ["노무비", "재료비", "경비", "계"] as const; +const PARTS = ["노무비", "재료비", "경비"] as const; +type Col = (typeof COLS)[number]; const KIND: Record = { 자원: "자기 자원", 덧줄: "덧줄" }; const TONE: Record = { 더함: "success", 덮음: "warning", 뺌: "neutral" }; +const ORDER = "가나다라마바사아자차카타파하"; + +/** 묶음 번호 — 가 · 나 · 다 … (열네 개를 넘으면 숫자) */ +const mark = (at: number): string => (at < ORDER.length ? (ORDER[at] ?? "") : String(at + 1)); const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { const out = document.createElement(tag); @@ -24,7 +32,12 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => return out; }; -function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void { +const num = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +function cells(line: HTMLElement, get: (k: Col) => unknown): void { for (const k of COLS) { const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0"); cell.dataset.eqSumCol = k; @@ -32,7 +45,7 @@ function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): v } } -function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement { +function row(kind: string, name: string, get: (k: Col) => unknown): HTMLElement { const line = node("div", "ui-eq__sum-row"); line.dataset.eqSumKind = kind; line.append(node("span", "ui-eq__sum-name", name)); @@ -40,19 +53,33 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn return line; } -/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */ -function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks): HTMLElement { - const item = node("div", "ui-eq__sum-item"); - const line = node("div", "ui-eq__sum-row"); - line.dataset.eqSumKind = r.갈래; - const name = node("span", "ui-eq__sum-name"); +/** 묶음 안 한 줄 「노무비 : 값」 */ +const part = (label: string, text: string): HTMLElement => { + const line = node("div", "ui-eq__grp-line"); + line.append( + node("span", "ui-eq__grp-key", label), + node("span", "ui-eq__op", ":"), + node("span", "ui-eq__grp-val", text), + ); + line.dataset.eqGrpPart = label; + return line; +}; + +/** 기준 단가산출 묶음 하나 — 「가. 이름」 머리(+ 상태 · 접힘 수식) → 노 · 재 · 경 줄 → 소 계 */ +function group(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks): HTMLElement { + const box = node("div", "ui-eq__grp"); + box.dataset.eqSumItem = r.갈래; + box.dataset.eqSumState = r.상태 ?? "바탕"; + const name = node("div", "ui-eq__grp-head"); + const title = r.갈래 === r.이름 ? (KIND[r.갈래] ?? r.이름) : r.이름; const tree = r.나무; const id = `sum:${at}:${r.이름}`; const body = node("div", "ui-eq__sum-tree"); body.hidden = true; + name.append(node("span", "ui-eq__grp-mark", `${mark(at)}.`)); if (tree) { const head = createButton({ - label: r.이름, + label: title, variant: "ghost", size: 22, iconStart: "chevronRight", @@ -66,68 +93,89 @@ function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks) if (!body.childElementCount) body.append( hooks?.body?.(r, at) ?? - createEqChain(tree.줄, { - path: id, - open, - resultUnit: tree.결과단위, - numbered: false, - }), + createEqChain(tree.줄, { path: id, open, resultUnit: tree.결과단위 }), ); } else open.delete(id); - item.dataset.open = on ? "1" : ""; + box.dataset.open = on ? "1" : ""; body.hidden = !on; }; name.append(head); if (open.has(id)) toggle(true); - } else name.append(node("span", "ui-eq__sum-label", r.이름)); + } else name.append(node("span", "ui-eq__sum-label", title)); if (r.상태 && r.상태 !== "바탕") { const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : ""; name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 })); } name.append(...(hooks?.head?.(r, at) ?? [])); - if (r.합밖) { - name.append(createBadge("합 밖", "neutral", { size: 18 })); - line.dataset.eqSumOut = "1"; - } + const out = !!r.합밖; + if (out) name.append(createBadge("합 밖", "neutral", { size: 18 })); const notes: string[] = []; - if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`); + if (out) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`); if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null) notes.push(`× ${eqText(r.수량)}`); if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함") notes.push(`원래 ${eqText(r.원래)}`); if (r.까닭) notes.push(r.까닭); if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · "))); - line.dataset.eqSumState = r.상태 ?? "바탕"; - line.append(name); - cells(line, (k) => r[k]); - item.dataset.eqSumItem = r.갈래; - item.append(line, body); - return item; + box.append(name, body); + for (const k of PARTS) if (num(r[k]) !== 0) box.append(part(k, eqText(r[k]))); + const total = row("소계", "소 계", (k) => r[k]); + total.classList.add("ui-eq__grp-total"); + if (out) total.dataset.eqSumOut = "1"; + box.append(total); + return box; +} + +/** 맨 끝 「소 계」 묶음 — 묶음 소계를 더한 식 */ +function final(rows: EqSumRow[], at: number): HTMLElement { + const box = node("div", "ui-eq__grp"); + box.dataset.eqSumItem = "소계"; + const head = node("div", "ui-eq__grp-head"); + head.append( + node("span", "ui-eq__grp-mark", `${mark(at)}.`), + node("span", "ui-eq__sum-label", "소 계"), + ); + box.append(head); + for (const k of PARTS) { + const adds = rows.map((r) => num(r[k])).filter((v) => v !== 0); + if (!adds.length) continue; + const text = adds.map((v) => eqText(v)).join(" + "); + const sum = eqText(adds.reduce((a, b) => a + b, 0)); + box.append(part(k, adds.length > 1 ? `${text} = ${sum}` : text)); + } + return box; +} + +export interface EqSumParts { + /** 블록 이름 → 그 블록 식 줄 아래에 끼울 묶음 */ + inline: Map; + /** 수식 영역 맨 끝 — 남은 묶음 · 소 계 · 끝수 · 합계 */ + tail: HTMLElement; } export function createEqSum( tree: Pick, open: Set = new Set(), hooks?: EqSumHooks, -): HTMLElement | null { + lineKeys: string[] = [], +): EqSumParts | null { const sub = tree.소계; if (!sub?.줄.length) return null; + const inline = new Map(); const box = node("div", "ui-eq__sum"); box.dataset.eqSum = ""; - const head = node("div", "ui-eq__sum-row ui-eq__sum-head"); - head.append(node("span", "ui-eq__sum-name", "소계")); - for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k)); - box.append(head); sub.줄.forEach((r, at) => { - if (r.상태) box.append(itemRow(r, open, at, hooks)); - else - box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k])); + const one = group(r, open, at, hooks); + if (r.갈래 === "블록" && lineKeys.includes(r.이름) && !inline.has(r.이름)) + inline.set(r.이름, one); + else box.append(one); }); + box.append(final(sub.줄, sub.줄.length)); const rest = sub.끝수; const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0); if (odd) box.append(row("끝수", "끝수", (k) => rest[k])); box.append(row("합계", "합계", (k) => tree.합계[k])); const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · "); if (note) box.append(node("div", "ui-eq__sum-note", note)); - return box; + return { inline, tail: box }; }