diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css index 26c6401ea..e238371b0 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(16rem, 24rem); + 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: 900px) { + .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..b8e41e639 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 { @@ -467,23 +521,28 @@ export function createLabEquation(opt: LabEquationOptions) { } 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 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, + }), + ...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계 + ], + }); + defsBox = buildDefs(); + body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] })); } async function save(): Promise { @@ -494,7 +553,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 +573,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 +588,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/resources/master_data/scripts/master_equation_view.py b/resources/master_data/scripts/master_equation_view.py index ba49bb5f6..561c06c9a 100644 --- a/resources/master_data/scripts/master_equation_view.py +++ b/resources/master_data/scripts/master_equation_view.py @@ -47,6 +47,8 @@ def _link(node: dict, spec: dict | None, leaf: dict | None) -> str: 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 '표'}" @@ -70,7 +72,9 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[ for line in lines: head, tree = line.get("결과"), line.get("이름식") if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict): - if tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식 + 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, [] 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/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index dd7ef3587..e801a1d4e 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; } /* 하위 로직 블록 — 접힘 */ @@ -313,12 +266,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 +358,82 @@ .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); +} 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..54b9b0f56 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,16 @@ 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; /** 결과 줄 값 뒤 단위 */ resultUnit?: string; - /** 줄 번호 1 부터 보임 */ - numbered?: boolean; } const BUNDLE: [string, string][] = [ @@ -67,52 +65,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 +101,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); @@ -183,7 +134,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 +152,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 +175,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 +204,12 @@ 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); 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..7fecae481 100644 --- a/ui_template/ui_template_equation_sum.ts +++ b/ui_template/ui_template_equation_sum.ts @@ -70,7 +70,6 @@ function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks) path: id, open, resultUnit: tree.결과단위, - numbered: false, }), ); } else open.delete(id);