diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 02a0f7afb..8f023ef59 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -43,6 +43,7 @@ import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet"; +import { createLabEquation } from "./M01_MasterData_UI_LogicLab_Equation"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -111,6 +112,14 @@ export async function mountM01LogicLab( edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길) run: () => calc.querySelector("[data-calc-run]")?.click(), }); + const equation = createLabEquation({ + admin, + blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김 + onSaved: () => { + const was = opened; // reload 가 고름을 비우므로 먼저 기억 + if (was?.row?.키) void reload().then(() => open(was.id, was.sub, was.row!.키)); + }, + }); const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); @@ -128,7 +137,11 @@ export async function mountM01LogicLab( (item) => void open(item.id, item.sub, item.key), (item, alias) => void onAlias(item, alias), ); - const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); + const back = createButton({ + label: tx("Bar_Back"), + variant: "ghost", + onClick: () => void equation.canLeave().then((ok) => ok && show(null)), + }); const persist = (): void => { try { @@ -243,7 +256,9 @@ export async function mountM01LogicLab( calcHost: calc, admin, sheet: sheet.root, + equation: equation.root, }); + if (current.origKey) equation.draw(current.origKey, current.version, current.row as LogicRow); sheet.draw(current.row as LogicRow, current.original, current.values); }; @@ -321,7 +336,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened) show(null); + if (opened) void equation.canLeave().then((ok) => ok && show(null)); }, }), ); @@ -502,6 +517,7 @@ export async function mountM01LogicLab( show(null); showLoadingOverlay(); const openKeyOn = async (key: string): Promise => { + if (!(await equation.canLeave())) return; const found = allLogics.find((x) => x.키 === key); if (found) await open(logicId(found.구분, found.키), found.구분, found.키); else showToast(tx("Load_Failed"), "error"); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ab8630f00..43746628a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Detail.ts - * 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로 + * 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함) * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로) * ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`) * 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜) @@ -39,6 +39,8 @@ export interface DetailContext extends HoContext { cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType }; /** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */ sheet?: HTMLElement; + /** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */ + equation?: HTMLElement; } const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => @@ -235,6 +237,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { // 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠 host.replaceChildren( ...infoBox(ctx), + ...(ctx.equation ? [block(ctx.equation, "equation")] : []), ...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), block( diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css new file mode 100644 index 000000000..26c6401ea --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css @@ -0,0 +1,69 @@ +/* 수식 탭(796-2) — 배치만 · 식 그림은 공통 수식 부품 · 색은 공통 값 */ +.m01-eq { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + container-type: inline-size; +} + +.m01-eq__options .ui-toolbar { + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-12); +} + +.m01-eq__opt { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 14rem; +} + +.m01-eq__opt > .ui-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m01-eq__ctl { + min-width: 0; +} + +.m01-eq__ctl :is(select, input) { + width: 100%; + max-width: 14rem; + text-overflow: ellipsis; +} + +.m01-eq__notice:empty { + display: none; +} + +.m01-eq__edit { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.m01-eq__fx { + min-width: 0; +} + +.m01-eq__form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--spacing-8) var(--spacing-12); +} + +.m01-eq__why:empty { + display: none; +} + +.m01-eq__why { + margin-top: var(--spacing-8); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts new file mode 100644 index 000000000..ea23943ae --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -0,0 +1,513 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Equation.ts + * 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`(개정 확인) + * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) + * 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게) + * ========================================================================== */ + +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 { createEqChain } from "@ui/ui_template_equation_line"; +import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation"; +import { + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; +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"; +import "./M01_MasterData_UI_LogicLab_Equation.css"; + +const TEXT = { + Title: ["수식", "Equation"], + Save: ["수식 저장", "Save equation"], + Discard: ["수식 고침 버림", "Discard edits"], + Add: ["변수 더함", "Add variable"], + Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"], + Now: ["지금", "Now"], + Before: ["고치기 전", "Before"], + Blocked: [ + "시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)", + "Unsaved edits elsewhere — save or discard first (showing saved version)", + ], + 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"], + Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"], + Leave_Ok: ["버림", "Discard"], + Saved: ["수식을 저장함", "Equation saved"], + Total: ["계", "Total"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT, n = 0): string => + (TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n)); + +interface Read { + ok?: boolean; + reason?: string; + 수식?: EqTree; + 되나?: boolean; + 까닭?: string[]; + 줄?: LogicRow; + 전?: Record; + 후?: Record; +} + +async function post(path: string, body: unknown, signal?: AbortSignal): Promise { + const r = await fetch(`${API_BASE_URL}/m01${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText); + return data; +} + +const CACHE = "m01_equation_edits"; +const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록) +const loadCache = (): Record => { + try { + return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record; + } catch { + return {}; + } +}; + +export interface LabEquationOptions { + /** 시스템 관리자 — 고침 도구 */ + admin: boolean; + /** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */ + blocked: () => boolean; + /** 저장 뒤 — 상세를 저장본으로 다시 엶 */ + onSaved: () => void; +} + +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": "" } }); + const notice = el("div", { className: "m01-eq__notice" }); + const body = el("div", { className: "m01-eq__body" }); + const root = el("div", { + className: "m01-eq", + attrs: { "data-lab-equation": "" }, + children: [bar, optionsBar, notice, body], + }); + let key = ""; + let version = ""; + let row: LogicRow | null = null; + let rowNow: LogicRow | null = null; + let eq: EqTree | null = null; + let edits: EqEdit[] = []; + let inputs: Record = {}; + let before: Record | null = null; + let why = ""; + let selected: string | null = null; + let ctl: AbortController | null = null; + let timer = 0; + let paintedBlocked = false; + const open = new Set(); + const drafts = new Map(); + const canEdit = (): boolean => opt.admin && !opt.blocked(); + + const body0 = (): Record => ({ key, inputs: inputsOf(inputs), 고침: edits }); + const persist = (): void => { + const all = loadCache(); + if (edits.length) all[key] = edits; + else delete all[key]; + try { + sessionStorage.setItem(CACHE, JSON.stringify(all)); + } catch { + /* 캐시가 막혀도 화면 안 고침은 남음 */ + } + }; + + /** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */ + async function refresh(): Promise { + ctl?.abort(); + const mine = (ctl = new AbortController()); + try { + const got = edits.length + ? await post("/logic/equation/preview", body0(), mine.signal) + : await post("/logic/equation", body0(), mine.signal); + if (mine !== ctl) return; + if (got.되나 === false || got.ok === false) { + why = (got.까닭 ?? [got.reason ?? ""]).join(" · "); + } else { + why = ""; + eq = got.수식 ?? null; + rowNow = got.줄 ?? null; + before = edits.length ? (got.전 ?? null) : null; + } + } catch (error) { + if (mine.signal.aborted) return; + why = error instanceof Error ? error.message : String(error); + } + paint(); + } + + /** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */ + async function commit(list: EqEdit[]): Promise { + const got = await post("/logic/equation/preview", { + ...body0(), + 고침: [...edits, ...list], + }).catch((error: unknown): Read => ({ + 되나: false, + 까닭: [error instanceof Error ? error.message : String(error)], + })); + if (!got.되나) return got.까닭 ?? []; + edits = [...edits, ...list]; + persist(); + ctl?.abort(); + why = ""; + eq = got.수식 ?? eq; + rowNow = got.줄 ?? rowNow; + before = got.전 ?? before; + paint(); + drawOptions(); + return []; + } + + const askAgain = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void refresh(), 150); + }; + + // ── 옵션 가로 ────────────────────────────────────────────────────── + function optionNode(spec: NonNullable[number], auto: unknown): HTMLElement { + const item: ItemInput = { + 이름: spec.이름, + 단위: spec.단위, + 고르기: spec.고르기, + 범위: spec.범위, + 자동값: auto, + }; + const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12); + const set = (v: string | number | null): void => { + if (v === null || v === "") delete inputs[spec.이름]; + else inputs[spec.이름] = String(v); + askAgain(); + }; + let control: HTMLElement; + if ((spec.고르기?.length ?? 0) > BIG) { + // 찾기형 — 입력 + 목록(칠수록 좁혀짐) + const box = createInputField({ + value: inputs[spec.이름] ?? "", + compact: true, + size: 24, + placeholder: auto === null || auto === undefined ? "" : String(auto), + }); + const list = el("datalist", { + attrs: { id: `m01-eq-list-${spec.이름}` }, + children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })), + }); + box.input.setAttribute("list", list.id); + box.input.title = box.input.value || box.input.placeholder; + box.input.addEventListener("change", () => { + box.input.title = box.input.value; + set(box.input.value.trim()); + }); + control = el("div", { children: [box.root, list] }); + } else { + const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v)); + const tip = (): void => { + const c = made.control; + c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value; + }; + made.control.addEventListener("change", tip); + made.control.addEventListener("input", tip); + tip(); + control = el("div", { children: [made.control, made.error] }); + } + control.classList.add("m01-eq__ctl"); + const cell = el("div", { className: "m01-eq__opt", children: [label, control] }); + cell.dataset.eqOption = spec.이름; + return cell; + } + + function drawOptions(): void { + const specs = (rowNow ?? row)?.입력 ?? []; + const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v])); + optionsBar.replaceChildren( + ...(specs.length + ? [ + createToolbar({ + items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)), + size: 24, + label: t("Options"), + }), + ] + : []), + ); + } + + // ── 고침 도구 ────────────────────────────────────────────────────── + const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [ + ["frac", "a⁄b", "Frac"], + ["paren", "( )", "Paren"], + ["sub", "x₂", "Sub"], + ["sup", "x²", "Sup"], + ["percent", "%", "Percent"], + ]; + + const check = + (line: EqLine) => + async (text: string, signal: AbortSignal): Promise => { + const got = await post( + "/logic/equation/preview", + { ...body0(), 고침: [...edits, formulaEdit(line, text)] }, + signal, + ); + const now = got.후 ? eqText(got.후[total(got.후)]) : ""; + return { + 되나: !!got.되나, + 까닭: 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 cell = createFormulaCell({ + key: id, + spot: id, + shown, + origin: shown, + drafts, + check: check(line), + put: (text) => { + if (text === null) return; + void commit([formulaEdit(line, text)]).then((bad) => { + if (bad.length) showToast(bad.join(" · "), "error"); + }); + }, + 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 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; + } + + function varDialog(node: { 열쇠?: string }): void { + const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠); + if (!v || !canEdit()) return; + openVarDialog({ + row: v, + value: inputs[v.열쇠] ?? "", + commit, + onValue: (value) => { + if (value) inputs[v.열쇠] = value; + else delete inputs[v.열쇠]; + drawOptions(); + void refresh(); + }, + onDropped: (k) => { + delete inputs[k]; + drawOptions(); + }, + }); + } + + // ── 그림 ────────────────────────────────────────────────────────── + function drawBar(): void { + const sums = eq?.합계 ?? {}; + const mark = total(sums); + const now = sums[mark]; + const old = before?.[mark]; + const diff = Number(now) - Number(old); + const items: (HTMLElement | "divider" | "spacer")[] = [ + createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14), + ]; + if (before) { + items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14)); + if (Number.isFinite(diff) && diff) + items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning")); + } + if (canEdit()) { + items.push("spacer"); + if (edits.length) { + items.push( + createBadge(t("Edits", edits.length), "warning"), + createButton({ + label: t("Discard"), + variant: "ghost", + size: 24, + onClick: () => void discard(), + }), + createButton({ label: t("Save"), size: 24, onClick: () => void save() }), + ); + } else items.push(createText(t("Hint"), "muted", 12)); + } + bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") })); + } + + function paint(): void { + paintedBlocked = opt.blocked(); + drawBar(); + notice.replaceChildren( + ...(paintedBlocked && opt.admin + ? [createBanner({ tone: "warning", text: t("Blocked") })] + : []), + ...(why ? [createBanner({ tone: "danger", text: why })] : []), + ); + if (!eq) { + body.replaceChildren(createText(t("Wait"), "muted")); + return; + } + const edit = canEdit(); + 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, + }), + ); + } + + async function save(): Promise { + if (!canEdit() || !edits.length) return; + showLoadingOverlay(); + try { + const done = await saveRevised((ok) => + post("/logic/equation/save", { ...body0(), version, 개정확인: ok }), + ); + if (!done) return; // 개정 확인 취소 = 고침 그대로 + [edits, selected] = [[], null]; + persist(); + showToast(t("Saved"), "success"); + await refresh(); + drawOptions(); + opt.onSaved(); + } catch (error) { + const detail = + error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null; + showToast( + detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)), + "error", + ); + } finally { + hideLoadingOverlay(); + } + } + + async function discard(): Promise { + edits = []; + selected = null; + drafts.clear(); + persist(); + await refresh(); + drawOptions(); + } + + return { + root, + /** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */ + draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => { + version = nextVersion; + row = nextRow; + if (nextKey !== key) { + [key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null]; + inputs = {}; + edits = loadCache()[nextKey] ?? []; + open.clear(); + drafts.clear(); + paint(); + drawOptions(); + void refresh().then(drawOptions); + } else if (opt.blocked() !== paintedBlocked) paint(); + }, + dirty: (): boolean => edits.length > 0, + /** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */ + canLeave: async (): Promise => { + if (!edits.length) return true; + if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false; + await discard(); + return true; + }, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts new file mode 100644 index 000000000..430174187 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts @@ -0,0 +1,247 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_EquationVar.ts + * 수식 탭 변수 창(PLAN 796-2 · 요구 657) — 변수 고침 · 변수 더함 · 변수 지움(쓰는 곳 있으면 막고 목록 보임). + * 고침 창 = 이름 · 기호 · 위첨자 · 아래첨자 · 단위 · 값(입력 변수만 · 시험 값 · 저장 안 함) · [적용] · [변수 지움] + * 더함 창 = 갈래(입력 · 중간) · 열쇠 · 이름 · 기호 · 첨자 · 단위 · 기본값(입력) | 식 · 자리(중간) + * 적용 = 부르는 쪽 `commit` 이 서버 미리 셈(`/logic/equation/preview`)을 거쳐 되면 [] · 안 되면 까닭 — 까닭은 창 안 띠에 그대로 + * 부품 = 모달 · 입력 · 고르기 · 단추 · 띠 알림 · 열쇠 · 식 검사 · 이름 겹침은 서버 몫 + * ========================================================================== */ + +import type { EqVarRow } 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 { currentLanguageIndex } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { addEdit, varEdits, type EqEdit, type VarForm } from "./logic/M01_Logic_Equation_Model"; + +const TEXT = { + VarTitle: ["변수 고침", "Edit variable"], + AddTitle: ["변수 더함", "Add variable"], + Close: ["닫기", "Close"], + Apply: ["적용", "Apply"], + Drop: ["변수 지움", "Delete variable"], + Key: ["열쇠 (식이 부르는 이름 · 안 바뀜)", "Key (used by formulas · fixed)"], + Name: ["이름", "Name"], + Sign: ["기호", "Symbol"], + Up: ["위첨자", "Superscript"], + Down: ["아래첨자", "Subscript"], + Unit: ["단위", "Unit"], + Value: ["값 (시험 값 · 저장 안 함)", "Value (trial · not saved)"], + Digits: ["보이는 자리 (0~6 · 빈 칸 = 전체 자리)", "Digits (0-6 · blank = full)"], + Kind: ["갈래", "Kind"], + Input: ["입력", "Input"], + Mid: ["중간", "Intermediate"], + Default: ["기본값", "Default"], + Formula: ["식", "Formula"], + After: ["자리", "Place"], + End: ["맨 끝", "End"], + Top: ["맨 위", "Top"], + Why: ["안 됨", "Not applied"], + Hint: ["이름 · 기호 · 첨자를 바꾸면 쓰는 모든 식에 함께 보임", "Renames show in every formula"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export type Commit = (edits: EqEdit[]) => Promise; + +const grid = (...kids: HTMLElement[]): HTMLElement => + el("div", { className: "m01-eq__form", children: kids }); + +/** 까닭 띠 — 있으면 보임 · 없으면 비움 */ +function reasonBox(): { root: HTMLElement; set: (why: string[]) => void } { + const root = el("div", { className: "m01-eq__why" }); + return { + root, + set: (why) => + root.replaceChildren( + ...(why.length + ? [createBanner({ tone: "danger", title: t("Why"), text: why.join(" · ") })] + : []), + ), + }; +} + +export interface VarDialog { + row: EqVarRow; + /** 입력 변수 시험 값(옵션 칸 글) */ + value: string; + commit: Commit; + /** 시험 값을 바꿈(저장 안 함) */ + onValue: (value: string) => void; + /** 변수를 지운 뒤 — 시험 값 비움 */ + onDropped: (key: string) => void; +} + +export function openVarDialog(o: VarDialog): void { + const v = o.row; + const field = (label: string, value: string): ReturnType => + createInputField({ label, value, size: 28 }); + const name = field(t("Name"), v.이름); + const sign = field(t("Sign"), v.기호); + const up = field(t("Up"), v.위); + const down = field(t("Down"), v.아래); + const unit = field(t("Unit"), v.단위); + const val = field(t("Value"), o.value); + const shown = v.표시 === undefined ? "" : String(v.표시); + const digits = field(t("Digits"), shown); + const why = reasonBox(); + openModal({ + title: `${t("VarTitle")} · ${v.열쇠}`, + closeLabel: t("Close"), + width: "40rem", + mount: (body, close, actions) => { + body.append( + el("p", { className: "ui-text ui-text--muted", text: t("Hint") }), + grid( + name.root, + sign.root, + up.root, + down.root, + unit.root, + ...(v.갈래 === "입력" ? [val.root] : [digits.root]), + ), + why.root, + ); + actions.append( + createButton({ + label: t("Drop"), + variant: "danger-ghost", + onClick: async () => { + const bad = await o.commit([{ 할일: "지움", 열쇠: v.열쇠 }]); + why.set(bad); + if (bad.length) return; + o.onDropped(v.열쇠); + close(); + }, + }), + createButton({ + label: t("Apply"), + onClick: async () => { + const now: VarForm = { + 열쇠: v.열쇠, + 이름: name.input.value.trim(), + 기호: sign.input.value.trim(), + 위: up.input.value.trim(), + 아래: down.input.value.trim(), + 단위: unit.input.value.trim(), + 표시: digits.input.value.trim(), + }; + const edits = varEdits( + { + 열쇠: v.열쇠, + 이름: v.이름, + 기호: v.기호, + 위: v.위, + 아래: v.아래, + 단위: v.단위, + 표시: shown, + }, + now, + ); + const bad = edits.length ? await o.commit(edits) : []; + why.set(bad); + if (bad.length) return; + if (v.갈래 === "입력" && val.input.value.trim() !== o.value) + o.onValue(val.input.value.trim()); + close(); + }, + }), + ); + }, + }); +} + +export interface AddDialog { + /** 중간 줄 열쇠 차례(자리 고르기) */ + mids: string[]; + commit: Commit; +} + +export function openAddDialog(o: AddDialog): void { + const field = (label: string, value = ""): ReturnType => + createInputField({ label, value, size: 28 }); + const kind = createSelectField({ + label: t("Kind"), + options: [ + { value: "입력", text: t("Input") }, + { value: "중간", text: t("Mid") }, + ], + value: "입력", + size: 28, + }); + const key = field(t("Key")); + const name = field(t("Name")); + const sign = field(t("Sign")); + const up = field(t("Up")); + const down = field(t("Down")); + const unit = field(t("Unit")); + const dflt = field(t("Default")); + const expr = field(t("Formula")); + const place = createSelectField({ + label: t("After"), + options: [ + { value: "", text: t("End") }, + { value: "\u0000top", text: t("Top") }, + ...o.mids.map((m) => ({ value: m, text: m })), + ], + value: "", + size: 28, + }); + const why = reasonBox(); + const sync = (): void => { + const mid = kind.select.value === "중간"; + dflt.root.hidden = mid; + expr.root.hidden = place.root.hidden = !mid; + }; + kind.select.addEventListener("change", sync); + sync(); + openModal({ + title: t("AddTitle"), + closeLabel: t("Close"), + width: "40rem", + mount: (body, close, actions) => { + body.append( + grid( + kind.root, + key.root, + name.root, + sign.root, + up.root, + down.root, + unit.root, + dflt.root, + expr.root, + place.root, + ), + why.root, + ); + actions.append( + createButton({ + label: t("Apply"), + onClick: async () => { + const mid = kind.select.value === "중간"; + const at = place.select.value; + const edit = addEdit( + { + 열쇠: key.input.value, + 이름: name.input.value, + 기호: sign.input.value, + 위: up.input.value, + 아래: down.input.value, + 단위: unit.input.value, + 갈래: mid ? "중간" : "입력", + 기본값: dflt.input.value.trim(), + 식: expr.input.value.trim(), + }, + at === "\u0000top" ? "" : at || undefined, + ); + const bad = await o.commit([edit]); + why.set(bad); + if (!bad.length) close(); + }, + }), + ); + }, + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index f33aece44..dc8205f1a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -199,6 +199,7 @@ } /* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */ +.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]), .m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]), .m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]), .m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index b1e070df0..4b0fd3b51 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -35,6 +35,7 @@ const TEXT = { Calc_Title: ["시험 계산", "Trial calculation"], View_Est: ["인력·재료·경비", "Labor·material·expense"], View_Sheet: ["시트", "Sheet"], + View_Equation: ["수식", "Equation"], Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"], View_Label: ["보기", "View"], G_labor: ["인력", "Labor"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index 32ffd6bfa..3872e2c0f 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -1,17 +1,17 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_View.ts * 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」 - * 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임 + * 보기 여섯 = 수식(기본 · 796-2) · 단가산출 상세 · 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임 * 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지 * 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위) - * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트) + * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식) * ========================================================================== */ import { createSegment } from "@ui/ui_template_segment"; import { createToolbar } from "@ui/ui_template_toolbar"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -export type LabView = "sheet" | "ho" | "est" | "calc" | "formula"; +export type LabView = "equation" | "sheet" | "ho" | "est" | "calc" | "formula"; /** 덩이에 보기 이름표를 붙여 돌려줌 */ export const block = (node: T, view: LabView): T => { @@ -21,16 +21,17 @@ export const block = (node: T, view: LabView): T => { /** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */ export function createViewPicker(host: HTMLElement): { root: HTMLElement } { - host.dataset.view = "sheet"; + host.dataset.view = "equation"; const segment = createSegment({ options: [ - { value: "sheet", label: tl("View_Sheet") }, + { value: "equation", label: tl("View_Equation") }, { value: "ho", label: tl("Ho_Title") }, + { value: "sheet", label: tl("View_Sheet") }, { value: "est", label: tl("View_Est") }, { value: "calc", label: tl("Calc_Title") }, { value: "formula", label: tl("Formula_Title") }, ], - value: "sheet", + value: "equation", size: 32, ariaLabel: tl("View_Label"), onChange: (v) => (host.dataset.view = v), diff --git a/M01_MasterData/logic/M01_Logic_Equation_Model.ts b/M01_MasterData/logic/M01_Logic_Equation_Model.ts new file mode 100644 index 000000000..aaf2b9ace --- /dev/null +++ b/M01_MasterData/logic/M01_Logic_Equation_Model.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * M01_MasterData/logic/M01_Logic_Equation_Model.ts + * 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청. + * 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦 + * 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만 + * ========================================================================== */ + +import type { EqLine } from "@ui/ui_template_equation"; + +export interface EqEdit { + 할일: "식" | "더함" | "지움" | "변수" | "표시"; + [key: string]: unknown; +} + +/** 식 줄의 고침 자리(`중간:<열쇠>` · `결과` · `호표:` · `덧줄:`) */ +export const spotOf = (line: EqLine): string => + line.갈래 === "호표" || line.갈래 === "덧줄" + ? `${line.갈래}:${line.차례 ?? 0}` + : line.갈래 === "결과" + ? "결과" + : `중간:${line.열쇠}`; + +export const formulaEdit = (line: EqLine, text: string): EqEdit => ({ + 할일: "식", + 자리: spotOf(line), + 식: text, +}); + +/** 아이콘 줄 — 식 칸 글에 끼우는 꼴 */ +export type InsertKind = "frac" | "paren" | "sub" | "sup" | "percent"; + +/** 선택 영역 `[from, to)` 를 아이콘 꼴로 바꾼 글 · 커서 자리 */ +export function insertInto( + kind: InsertKind, + text: string, + from: number, + to: number, +): { text: string; caret: number } { + const head = text.slice(0, from); + const pick = text.slice(from, to); + const tail = text.slice(to); + const put = (mid: string, caret: number): { text: string; caret: number } => ({ + text: head + mid + tail, + caret: head.length + caret, + }); + switch (kind) { + case "frac": // 분수 = (위) / (아래) — 고른 글이 위 · 커서는 아래 괄호 안 + return pick ? put(`(${pick}) / ()`, pick.length + 6) : put("() / ()", 1); + case "paren": + return pick ? put(`(${pick})`, pick.length + 2) : put("()", 1); + case "sub": + return put(`${pick}_`, pick.length + 1); + case "sup": + return put(`${pick}^`, pick.length + 1); + case "percent": + return put(`${pick} / 100`, pick.length + 6); + } +} + +/** 옵션 값 — 수 꼴 글은 수로(엔진 입력 꼴) · 빈 값은 안 보냄(자동값) */ +export function inputsOf(values: Record): Record { + const out: Record = {}; + for (const [name, raw] of Object.entries(values)) { + if (raw === "") continue; + out[name] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; + } + return out; +} + +export interface VarForm { + 열쇠: string; + 이름: string; + 기호: string; + 위: string; + 아래: string; + 단위: string; + 갈래?: "입력" | "중간"; + 설명?: string; + 기본값?: string; + 식?: string; + /** 보이는 자리 글(중간 줄 · 빈 글 = 전체) */ + 표시?: string; +} + +/** 변수 고침 창 → 요청 목록(바꾼 칸만) — `보이는 자리`(중간 줄) = 0~6 · 빈 칸 = 전체 자리(`표시` 지움) */ +export function varEdits(before: VarForm, now: VarForm): EqEdit[] { + const out: EqEdit = { 할일: "변수", 열쇠: before.열쇠 }; + let changed = false; + for (const k of ["이름", "기호", "위", "아래", "단위"] as const) + if (now[k] !== before[k]) { + out[k] = now[k]; + changed = true; + } + const list: EqEdit[] = changed ? [out] : []; + if (now.표시 !== before.표시) { + const digits = now.표시 === "" || now.표시 === undefined ? null : Number(now.표시); + list.push({ 할일: "표시", 열쇠: before.열쇠, 표시: digits }); + } + return list; +} + +/** 변수 더함 창 → 요청 — 빈 칸은 안 보냄 */ +export function addEdit(form: VarForm, after?: string): EqEdit { + const out: EqEdit = { 할일: "더함", 갈래: form.갈래 ?? "입력", 열쇠: form.열쇠.trim() }; + for (const k of ["이름", "기호", "위", "아래", "단위", "설명"] as const) { + const v = (form[k] ?? "").trim(); + if (v) out[k] = v; + } + if (form.갈래 === "중간") { + out.식 = form.식 || "0"; + if (after !== undefined) out.뒤 = after; + } else if (form.기본값) out.기본값 = form.기본값; + return out; +} diff --git a/resources/tester/test_794_1_m01_sheet.py b/resources/tester/test_794_1_m01_sheet.py index cdaf07c19..8a060f6fe 100644 --- a/resources/tester/test_794_1_m01_sheet.py +++ b/resources/tester/test_794_1_m01_sheet.py @@ -73,10 +73,10 @@ def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None: assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장] assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet - assert len(lab.splitlines()) <= 637 + assert len(lab.splitlines()) <= 700 # 796-2 수식 탭 이음 14 줄 view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") - assert ( - 'host.dataset.view = "sheet";' in view + assert ( # 796-2 — 첫 탭 = 수식 · 시트는 뒤로(걷지 않음) + 'host.dataset.view = "equation";' in view and '{ value: "sheet", label: tl("View_Sheet") }' in view ) css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") diff --git a/resources/tester/test_796_2_equation_screen.py b/resources/tester/test_796_2_equation_screen.py new file mode 100644 index 000000000..6b986f28a --- /dev/null +++ b/resources/tester/test_796_2_equation_screen.py @@ -0,0 +1,136 @@ +"""796-2 수식 탭 화면 지킴(정적 · 서버 · 브라우저 없이) — 부품 상자만 · 수식 탭 첫 탭 · 아이콘 숨김 · 변수 고침 창 · 옵션 가로. + +화면 동작(분수 · 변수 풀이 · 고침 → 금액 · 변수 더함 · 지움 · 저장)은 ORCA 사용자 길로 확인 — 서버 모델은 `test_796_1_formula_model.py`. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M01 = ROOT / "M01_MasterData" +UI = ROOT / "ui_template" +PARTS = [UI / "ui_template_equation.ts", UI / "ui_template_equation_line.ts"] +PAGE = [ + M01 / "M01_MasterData_UI_LogicLab_Equation.ts", + M01 / "M01_MasterData_UI_LogicLab_EquationVar.ts", +] +MODEL = M01 / "logic" / "M01_Logic_Equation_Model.ts" +CSS = [UI / "ui_template_equation.css", M01 / "M01_MasterData_UI_LogicLab_Equation.css"] +RAW = re.compile(r" str: + return path.read_text(encoding="utf-8").replace("\r\n", "\n") + + +def test_부품_상자만_날_단추_입력_없음(): + for path in [*PARTS, *PAGE, MODEL]: + src = _text(path) + assert not RAW.search(src), f"{path.name} — 날 button · input" + # 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것 + page = _text(PAGE[0]) + _text(PAGE[1]) + for part in ( + "createIconButton", + "createButton", + "createToolbar", + "createFormulaCell", + "openModal", + ): + assert part in page, part + assert "createValueControl" in page # 옵션 값 칸 = 기존 값 칸 한 벌(M02_Labor_Values) + + +def test_색은_공통_값만_700줄_안(): + for path in CSS: + css = _text(path) + assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css), path.name + assert "var(--color-" in css + for path in [*PARTS, *PAGE, MODEL, *CSS, M01 / "M01_MasterData_UI_LogicLab.ts"]: + assert len(_text(path).splitlines()) <= 700, path.name + assert len(_text(M01 / "M01_MasterData_UI_LogicLab_Ho.ts").splitlines()) <= 700 + + +def test_계산_자리_화면은_모양만(): + model = _text(MODEL) + assert "fetch(" not in model and "document." not in model # logic/ = 화면 · fetch 없음 + for path in PARTS: + assert "fetch(" not in _text(path) # 부품은 그리기만 + page = _text(PAGE[0]) + assert page.count("fetch(") == 1 # 서버 길 한 곳 — 계산은 서버 한 벌(③) + for route in ('"/logic/equation"', '"/logic/equation/preview"', '"/logic/equation/save"'): + assert route in page, route + assert "saveRevised(" in page and "개정확인" in page # [저장] = 개정 확인 길 + assert "edits" in page and "sessionStorage" in page # 고침 목록 = 캐시 → [저장] 한 번 + assert "ctl?.abort()" in page # 옛 요청 끊음 + assert "setInterval" not in page and "autosave" not in page.lower() # 자동저장 없음 + + +def test_수식_탭이_첫_탭_단가산출_상세는_그대로(): + view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") + labels = re.findall(r'\{ value: "(\w+)", label: tl\("(\w+)"\) \}', view) + assert [v for v, _ in labels] == ["equation", "ho", "sheet", "est", "calc", "formula"] + assert 'host.dataset.view = "equation";' in view and 'value: "equation",\n size: 32' in view + detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") + assert 'block(ctx.equation, "equation")' in detail + assert 'block(section(tl("Ho_Title"), [formula]), "formula")' in detail # 옛 탭은 걷지 않음 + css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") + assert '[data-view="equation"] [data-block]:not([data-block="equation"])' in css + lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") + assert "createLabEquation({" in lab and "equation: equation.root," in lab + assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음 + + +def test_아이콘_줄은_고른_줄에만_고침은_관리자만(): + line = _text(PARTS[1]) + assert "o.selected === id ? o.slot?.(line, id) : null" in line # 고른 줄에만 식 칸 · 아이콘 줄 + page = _text(PAGE[0]) + for icon in ('"frac"', '"paren"', '"sub"', '"sup"', '"percent"'): + assert icon 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 "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page + + +def test_변수_고침_창_칸과_지움_막힘(): + var = _text(PAGE[1]) + for label in ( + 'Name: ["이름"', + 'Sign: ["기호"', + 'Up: ["위첨자"', + 'Down: ["아래첨자"', + 'Unit: ["단위"', + 'Digits: ["보이는 자리', + "Value:", + ): + assert label in var, label + assert '할일: "지움"' in var and "why.set(bad)" in var # 쓰는 곳 목록 = 서버 까닭 그대로 띠에 + assert "commit(" in var or "o.commit(" in var + assert "openAddDialog" in var and 'value: "중간"' in var # 입력 · 중간 더함 + model = _text(MODEL) + for job in ('"식"', '"더함"', '"지움"', '"변수"'): + assert job in model, job + + +def test_옵션_가로_찾기형_말줄임(): + page = _text(PAGE[0]) + assert "createToolbar({" in page and "optionNode(" in page + assert "BIG = 12" in page and "datalist" in page # 고르기 많으면 찾기형 + assert "title" in page # 마우스 올림 전체 글 + css = _text(CSS[1]) + assert "text-overflow: ellipsis" in css and "max-width: 14rem" in css + assert "askAgain" in page # 옵션 바꾸면 다시 셈 + + +def test_부품_모음_견본_한_줄(): + src = _text(UI / "ui_template_ui_kit_parts.ts") + rows = re.findall( + r'\{ key: "([^"]+)", name: "[^"]+", group: "(\w+)",[^\n]*file: "([^"]+)"', src + ) + assert ("equation", "compound", "ui_template_equation.ts") in rows + assert len(src.splitlines()) <= 700 + head = _text(PARTS[0]) + for word in ("분수", "첨자", "조건 칩", "크기", "상태", "쓰는 법"): + assert word in head, word diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css new file mode 100644 index 000000000..5a0f77f5f --- /dev/null +++ b/ui_template/ui_template_equation.css @@ -0,0 +1,324 @@ +/* 수식(796-2) — 분수 · 첨자 · 괄호 · 줄 · 변수 풀이 · 블록 · 색은 공통 값만 */ +.ui-eq { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + container-type: inline-size; + font-size: var(--text-body); + color: var(--color-text-body); +} + +/* ── 식 그림 ── */ +.ui-eq__expr, +.ui-eq__body { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--spacing-4); + min-width: 0; +} + +.ui-eq__num, +.ui-eq__str { + font-variant-numeric: tabular-nums; +} + +.ui-eq__str { + color: var(--color-text-secondary); +} + +.ui-eq__op { + color: var(--color-text-secondary); + padding: 0 1px; +} + +.ui-eq__sym.is-latin { + font-style: italic; + font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif; +} + +.ui-eq__script { + font-size: 0.7em; + line-height: 0; + font-style: normal; +} + +.ui-eq__var { + border-radius: var(--radius-sm); +} + +.ui-eq__var.is-pick, +.ui-eq__block.is-pick { + cursor: pointer; +} + +.ui-eq__var.is-pick:hover, +.ui-eq__var.is-pick:focus-visible { + outline: 1px solid var(--color-accent); + background: var(--color-accent-soft); +} + +/* 분수 — 가로 줄 안에 위 · 아래를 세로로 쌓고 사이에 선 */ +.ui-eq__frac { + display: inline-flex; + flex-direction: column; + align-items: center; + vertical-align: middle; + min-width: 0; + max-width: 100%; +} + +.ui-eq__top { + padding: 0 var(--spacing-4) 2px; + border-bottom: 1px solid currentcolor; + text-align: center; +} + +.ui-eq__bottom { + padding: 2px var(--spacing-4) 0; + text-align: center; +} + +/* 괄호 — 안 키에 맞춰 늘어나는 곡선 */ +.ui-eq__paren { + display: inline-flex; + align-items: stretch; + gap: 2px; +} + +.ui-eq__paren::before, +.ui-eq__paren::after { + content: ""; + flex: none; + width: 5px; + border: 1.5px solid currentcolor; +} + +.ui-eq__paren::before { + border-right: 0; + border-radius: 8px 0 0 8px; +} + +.ui-eq__paren::after { + border-left: 0; + border-radius: 0 8px 8px 0; +} + +.ui-eq__inner { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ui-eq__fn { + color: var(--color-text-secondary); +} + +/* 조건 칩 · 하위 로직 칩 */ +.ui-eq__cond, +.ui-eq__block { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-pills); + background: var(--color-surface); + font-size: var(--text-body-sm); + max-width: 100%; +} + +.ui-eq__cond-key { + color: var(--color-warning); + font-weight: var(--font-weight-medium); +} + +.ui-eq__block { + border-color: var(--color-info); +} + +.ui-eq__block.is-pick:hover { + background: var(--color-accent-soft); +} + +.ui-eq__badge { + color: var(--color-info); +} + +.ui-eq__block-name { + max-width: 14em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ui-eq__block-val { + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-medium); +} + +/* ── 줄 ── */ +.ui-eq__line { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border-bottom: 1px solid var(--color-border); + 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); +} + +/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */ +.ui-eq__box { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + max-width: 100%; + min-width: 0; +} + +.ui-eq__res { + font-weight: var(--font-weight-bold); + font-variant-numeric: tabular-nums; +} + +.ui-eq__unit { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-regular); +} + +.ui-eq__raw { + font-family: var(--font-mono, monospace); + font-size: var(--text-body-sm); + overflow-wrap: anywhere; +} + +.ui-eq__bad { + color: var(--color-danger); + font-size: var(--text-caption); +} + +/* 변수 풀이 */ +.ui-eq__solve { + display: flex; + flex-direction: column; + gap: 2px; + 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; +} + +/* 하위 로직 블록 — 접힘 */ +.ui-eq__blockpanel { + margin-left: var(--spacing-16); + padding-left: var(--spacing-8); + border-left: 2px solid var(--color-info); +} + +.ui-eq__block-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.ui-eq__blockpanel[data-open] .ui-eq__block-head .ui-btn__icon { + transform: rotate(90deg); +} + +.ui-eq__bundle { + display: inline-flex; + flex-wrap: wrap; + gap: var(--spacing-8); + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; + 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); +} + +.ui-eq__block-body .ui-eq__line { + 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; +} diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts new file mode 100644 index 000000000..c66e15559 --- /dev/null +++ b/ui_template/ui_template_equation.ts @@ -0,0 +1,279 @@ +/* ============================================================================= + * ui_template_equation.ts + * 부품 「수식」(796-2 · 요구 655) — 수식 나무(서버 `master_equation.py` 나무 JSON)를 PPT 수식 꼴로 그림. + * 꼴 = 분수(가로 줄 안 세로 쌓기 · 분수선) · 위 · 아래첨자 · × ÷ + − · 비교 · 괄호(키 큰 곡선) · % · 단위 · + * 조건 칩(만약 → 걸린 쪽만) · 함수 · 하위 로직 칩(블록 · 접힘은 `ui_template_equation_line.ts`) + * 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄 + * 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만 + * 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts` + * 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠) + * ========================================================================== */ + +import "./ui_template_equation.css"; + +/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */ +export interface EqNode { + 꼴: string; + 글?: string; + 따옴?: boolean; + 값?: unknown; + 열쇠?: string; + 이름?: string; + 기호?: string; + 위?: string | EqNode; + 아래?: string | EqNode; + 단위?: string; + 갈래?: string; + 식?: string; + 칸?: string | null; + 표?: string; + 표이름?: string; + 행?: Record; + 좌?: EqNode; + 우?: EqNode; + 안?: EqNode; + 조건?: EqNode; + 참?: EqNode; + 거짓?: EqNode; + 걸림?: number; + 이름식?: EqNode; + 인자?: EqNode[]; + 묶음?: Record | null; + 결과단위?: string; + 나무?: EqTree | null; + 사슬?: boolean; +} + +/** 로직 하나의 수식(줄 · 변수표 · 합계) */ +export interface EqTree { + 열쇠: string; + 이름: string; + 결과단위: string; + 줄: EqLine[]; + 변수표: EqVarRow[]; + 합계: Record; +} + +export interface EqVarRow { + 열쇠: string; + 이름: string; + 기호: string; + 위: string; + 아래: string; + 단위: string; + 갈래: string; + 기본값: unknown; + 설명: string; + 표시?: number; +} + +export interface EqLine { + 갈래: string; + 열쇠: string; + 차례?: number; + 식: string; + 입력글?: string; + 까닭?: string; + 결과?: EqNode; + 이름식?: EqNode; + 값식?: EqNode; + 값?: unknown; + 글?: string; + 표시?: number; + 비목?: unknown; +} + +export interface EqHandlers { + /** 변수 칩을 누름(없으면 눌리지 않음) */ + onVar?: (node: EqNode) => void; + /** 하위 로직 칩을 누름 — 줄 아래 블록을 펴고 접음 */ + onBlock?: (node: EqNode) => void; + /** 블록 번호 매기기용 — 이 식 안 블록 목록(부르는 쪽이 비우고 씀) */ + blocks?: EqNode[]; +} + +const SHOW: Record = { + "==": "=", + "!=": "≠", + "<=": "≤", + ">=": "≥", + "*": "×", + "/": "÷", + "-": "−", +}; +const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 6 }); + +const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => { + const node = document.createElement("span"); + node.className = className; + node.append(...kids); + return node; +}; + +/** 값 → 글(수는 쉼표 · 6 자리까지) */ +export function eqText(value: unknown): string { + if (value === null || value === undefined || value === "") return ""; + const n = typeof value === "number" ? value : Number(value); + return typeof value !== "boolean" && Number.isFinite(n) && String(value).trim() !== "" + ? NUM.format(n) + : String(value); +} + +/** 변수 글(기호 · 아래 · 위) — 첨자가 없으면 기호만 */ +export function symbolOf(node: Pick): HTMLElement { + const sign = node.기호 || node.열쇠 || ""; + const box = span("ui-eq__sym", sign); + if (/^[A-Za-z]{1,2}$/.test(sign)) box.classList.add("is-latin"); + if (typeof node.아래 === "string" && node.아래) box.append(subOf("sub", node.아래)); + if (typeof node.위 === "string" && node.위) box.append(subOf("sup", node.위)); + return box; +} + +const subOf = (tag: "sub" | "sup", text: string): HTMLElement => { + const node = document.createElement(tag); + node.className = "ui-eq__script"; + node.textContent = text; + return node; +}; + +/** 한 줄 글(마우스 글 · 검사용) — 변수는 기호 · 값 꼴 */ +export function plainEq(node: EqNode | undefined): string { + if (!node) return ""; + switch (node.꼴) { + case "수": + case "글": + return node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""); + case "변수": { + const sign = node.기호 || node.열쇠 || ""; + const low = typeof node.아래 === "string" && node.아래 ? `_${node.아래}` : ""; + const up = typeof node.위 === "string" && node.위 ? `^${node.위}` : ""; + return sign + low + up; + } + case "블록": + return node.이름 || node.열쇠 || ""; + case "연산": + return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`; + case "분수": + return `(${plainEq(node.위 as EqNode)}) ÷ (${plainEq(node.아래 as EqNode)})`; + case "괄호": + return `(${plainEq(node.안)})`; + case "퍼센트": + return `${plainEq(node.안)}%`; + case "음": + return `−${plainEq(node.안)}`; + case "조건": + return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`; + case "함수": + return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`; + default: + return ""; + } +} + +/** 변수 한 개 — 눌리면 `onVar` */ +function variable(node: EqNode, h: EqHandlers): HTMLElement { + const box = span("ui-eq__var", symbolOf(node)); + box.dataset.kind = node.갈래 ?? ""; + box.dataset.eqVar = node.열쇠 ?? ""; + const value = eqText(node.값); + box.title = [node.이름 || node.열쇠, value ? `= ${value}${node.단위 ? ` ${node.단위}` : ""}` : ""] + .filter(Boolean) + .join(" "); + if (h.onVar) { + box.classList.add("is-pick"); + box.tabIndex = 0; + box.setAttribute("role", "button"); + const fire = (ev: Event): void => { + ev.stopPropagation(); + h.onVar?.(node); + }; + box.addEventListener("click", fire); + box.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev)); + } + return box; +} + +function block(node: EqNode, h: EqHandlers): HTMLElement { + const at = (h.blocks ?? []).push(node); + const value = eqText(node.값); + const chip = span( + "ui-eq__block", + span("ui-eq__badge", String.fromCodePoint(0x245f + at)), // ① ② … + span("ui-eq__block-name", node.이름 || node.열쇠 || ""), + value ? span("ui-eq__block-val", value) : "", + ); + chip.dataset.eqBlock = node.열쇠 ?? ""; + chip.title = `${node.열쇠 ?? ""} ${node.이름 ?? ""}`.trim(); + if (h.onBlock) { + chip.classList.add("is-pick"); + chip.tabIndex = 0; + chip.setAttribute("role", "button"); + const fire = (ev: Event): void => { + ev.stopPropagation(); + h.onBlock?.(node); + }; + chip.addEventListener("click", fire); + chip.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev)); + } + return chip; +} + +/** 수식 나무 → 인라인 요소 */ +export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement { + if (!node) return span("ui-eq__expr"); + const sub = (child: EqNode | string | undefined): HTMLElement => + typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h); + switch (node.꼴) { + case "수": + return span("ui-eq__num", node.글 ?? ""); + case "글": + return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? "")); + case "변수": + return variable(node, h); + case "블록": + return block(node, h); + case "연산": { + const sign = SHOW[node.기호 ?? ""] ?? node.기호 ?? ""; + return span("ui-eq__expr", sub(node.좌), span("ui-eq__op", sign), sub(node.우)); + } + case "분수": { + const frac = span( + "ui-eq__frac", + span("ui-eq__top", sub(node.위)), + span("ui-eq__bottom", sub(node.아래)), + ); + if (node.사슬) frac.dataset.chain = "1"; + return frac; + } + case "괄호": + return span("ui-eq__paren", span("ui-eq__inner", sub(node.안))); + case "퍼센트": + return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%")); + case "음": + return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안)); + case "조건": { + const pick = node.걸림 ?? 0; + const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건)); + chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`; + if (pick === 1 || pick === 2) + return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓)); + return span( + "ui-eq__expr", + chip, + span("ui-eq__op", "참"), + sub(node.참), + span("ui-eq__op", "거짓"), + sub(node.거짓), + ); + } + case "함수": { + const args = span("ui-eq__expr"); + (node.인자 ?? []).forEach((a, i) => + args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])), + ); + return span("ui-eq__expr", span("ui-eq__fn", node.이름 ?? ""), span("ui-eq__paren", args)); + } + default: + return span("ui-eq__expr", node.글 ?? ""); + } +} diff --git a/ui_template/ui_template_equation_line.ts b/ui_template/ui_template_equation_line.ts new file mode 100644 index 000000000..80ffb14d2 --- /dev/null +++ b/ui_template/ui_template_equation_line.ts @@ -0,0 +1,244 @@ +/* ============================================================================= + * ui_template_equation_line.ts + * 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`. + * 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처) + * 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글) + * 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음) + * 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22 + * 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open` + * 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })` + * 대신하는 옛 변종 = M01 · M02 시트 줄(`createCalcSheetRow`) 의 이름 | 식 | 값 한 줄(분수 · 풀이 없음) + * ========================================================================== */ + +import { createButton } from "./ui_template_button"; +import { + eqText, + plainEq, + renderEq, + symbolOf, + type EqHandlers, + type EqLine, + type EqNode, +} from "./ui_template_equation"; + +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; + /** 결과 줄 값 뒤 단위 */ + resultUnit?: string; + /** 줄 번호 1 부터 보임 */ + numbered?: boolean; +} + +const BUNDLE: [string, string][] = [ + ["노무비", "노"], + ["재료비", "재"], + ["경비", "경"], + ["계", "계"], +]; + +const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => { + const node = document.createElement("span"); + node.className = className; + node.append(...kids); + return node; +}; +const div = (className: string, ...kids: Node[]): HTMLDivElement => { + const node = document.createElement("div"); + node.className = className; + node.append(...kids); + return node; +}; + +/** 줄 이름표 — 호표 · 덧줄은 차례 · 그 밖은 열쇠 */ +export const lineId = (line: EqLine): string => + line.갈래 === "호표" || line.갈래 === "덧줄" + ? `${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.묶음; + if (got && typeof got === "object") + for (const [key, label] of BUNDLE) + if (got[key] !== undefined && got[key] !== null) + box.append(span("ui-eq__bundle-item", `${label} ${eqText(got[key])}`)); + if (!box.childElementCount && node.값 !== undefined && node.값 !== null) + box.append(span("ui-eq__bundle-item", eqText(node.값))); + return box; +} + +function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { + const id = `${o.path}/${node.열쇠 ?? at}`; + const body = div("ui-eq__block-body"); + const box = div("ui-eq__blockpanel"); + box.dataset.eqBlockPanel = node.열쇠 ?? ""; + box.dataset.blockId = id; + const head = createButton({ + label: `${String.fromCodePoint(0x245f + at)} ${node.이름 || node.열쇠 || ""}`, + variant: "ghost", + size: 22, + iconStart: "chevronRight", + onClick: () => toggle(), + }); + head.classList.add("ui-eq__block-head"); + const toggle = (force?: boolean): void => { + const on = force ?? !o.open.has(id); + if (on) { + o.open.add(id); + if (!body.childElementCount && node.나무) + body.append( + createEqChain(node.나무.줄, { + path: id, + open: o.open, + resultUnit: node.결과단위 ?? node.나무.결과단위, + numbered: false, + }), + ); + } else o.open.delete(id); + box.dataset.open = on ? "1" : ""; + body.hidden = !on; + }; + body.hidden = true; + box.append( + div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")), + body, + ); + if (o.open.has(id)) toggle(true); + return box; +} + +/** 줄 하나 */ +export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement { + const id = lineId(line); + const root = div("ui-eq__line"); + root.dataset.eqLine = id; + root.dataset.kind = line.갈래; + const blocks: EqNode[] = []; + const hand: EqHandlers = { + onVar: o.onVar, + blocks, + onBlock: (node) => { + root + .querySelector( + `[data-block-id="${CSS.escape(`${o.path}/${node.열쇠 ?? ""}`)}"] .ui-eq__block-head`, + ) + ?.click(); + }, + }; + 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( + span( + "ui-eq__box ui-eq__head", + head.꼴 !== "변수" + ? plainEq(head) + : line.갈래 === "중간" + ? renderEq(head, hand) // 누르면 변수 창 + : symbolOf(head), + ), + ); + const resUnit = + line.갈래 === "호표" || line.갈래 === "덧줄" + ? "원" + : line.갈래 === "결과" + ? (o.resultUnit ?? "") + : (head?.단위 ?? ""); + const result = span( + "ui-eq__box ui-eq__res", + 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)); + body.append(span("ui-eq__op", "="), name); + const same = + plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식); + if (line.값식 && !same) + body.append(span("ui-eq__op", "="), span("ui-eq__box ui-eq__val", renderEq(line.값식))); + body.append(span("ui-eq__op", "="), result); + } else { + body.append( + span("ui-eq__op", "="), + span("ui-eq__box ui-eq__raw", line.식), + span("ui-eq__bad", line.까닭 ?? ""), + ); + } + 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); + 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); + return root; +} + +/** 여러 줄 위에서 아래로 이음 */ +export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement { + return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o))); +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 82b99d544..f004d6e1d 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -29,6 +29,8 @@ import { createTabs } from "./ui_template_tabs"; import { el } from "./ui_template_elements_base"; import { createText } from "./ui_template_text"; import { createToggle } from "./ui_template_toggle"; +import { createEqChain } from "./ui_template_equation_line"; +import type { EqLine, EqNode } from "./ui_template_equation"; // 552-3 묶음 부품 import { createBanner } from "./ui_template_banner"; import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu"; @@ -208,6 +210,15 @@ const kitLampTable = (): HTMLElement => ? `ui-data-table__lamp${i ? " is-linked" : ""}` : `ui-data-table__lamp ui-data-table__lamp--only${i ? " is-linked" : ""}`, }).root; +/** 796-2 견본 — Q = 3,600 × q × K × f × E ÷ c_m (분수 · 첨자 · 변수 풀이) */ +const kitEquation = (): HTMLElement => { + const v = (열쇠: string, 기호: string, 값: string, 아래 = "", 갈래 = "입력", 이름 = 열쇠): EqNode => ({ 꼴: "변수", 열쇠, 이름, 기호, 아래, 값, 글: 값, 갈래 }); + const n = (글: string): EqNode => ({ 꼴: "수", 글, 값: 글 }); + const mul = (좌: EqNode, 우: EqNode): EqNode => ({ 꼴: "연산", 기호: "×", 좌, 우 }); + const name = (a: EqNode, b: EqNode): EqNode => ({ 꼴: "분수", 위: mul(mul(mul(mul(n("3,600"), a), b), v("f", "f", "0.7")), v("E", "E", "0.75")), 아래: v("c", "c", "21", "m", "중간", "1회 싸이클시간") }); + const line: EqLine = { 갈래: "결과", 열쇠: "결과", 식: "", 글: "81", 결과: { 꼴: "변수", 열쇠: "Q", 기호: "Q", 단위: "㎥/hr" }, 이름식: name(v("q", "q", "1", "", "입력", "버킷용량"), v("K", "K", "0.9", "", "찾기", "버킷계수")), 값식: { 꼴: "분수", 위: n("3,600 × 1 × 0.9 × 0.7 × 0.75"), 아래: n("21") } }; + return createEqChain([line], { path: "", open: new Set(), resultUnit: "㎥/hr" }); +}; const kitKeys = (...keys: string[]): DocumentFragment => { const f = document.createDocumentFragment(); f.append(...keys.map((k) => createBadge(k, "neutral"))); @@ -381,6 +392,8 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) }, { key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts", demo: (s) => (s === "default" ? kitCalc() : null) }, + { key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts", + demo: (s) => (s === "default" ? kitEquation() : null) }, { key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) }, { key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts", demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);