From 6f678fb91b57d4641ae03442dace47eda7d3126a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 08:42:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-5):=20=EC=A4=84=20=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=ED=91=9C=20=EA=B0=92=20=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=C2=B7=20=EA=B7=B8=EB=A3=B9=20=EA=B8=80=20=C2=B7=20=EC=A7=9D=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=EC=B1=84=EC=9A=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=ED=8B=80=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20=EA=B1=B7=EC=9D=8C(?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20683~685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 값 기호 화면 이름 = 고른 줄 이름(Q_수량2^표 → Q_특별인부 · 겹치면 2 · 3) — 왼쪽 · 산출근거 같음 · 칸 글은 화면 이름 ↔ 기호로 오감(문서 기호 그대로) - 단위 「줄 따름」 안 보임 · 표 값만 든 묶음 머리 = 「표 값」 · [식으로 풀기] → 「식 고치기」 - 산출근거 = 넓을 때 화면 고정 + 자기 스크롤(높이 = 스크롤 영역) · 나란히 놓는 탭 폭 1240 → 1040 - 글 「항목」 → 「그룹」(그룹 이름 · + 그룹 · 그룹 지움) - 짝 강조 = 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 모두 같은 바탕 채우기(테두리 걷음) - [틀로 저장] · 내 틀 목록(브라우저 저장) 걷음 · 공식 틀 고르기 = M01 단가산출 결과만 - 시험 test_804_4 표 값 화면 이름 1,518 전수(겹침 없음 · 식 글 왕복 같음) 더함 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack.css | 26 ++++-- A00_Common/line_stack_edit.ts | 32 +++---- A00_Common/line_stack_out.ts | 34 +++++--- A00_Common/line_stack_rows.ts | 86 ++++++++----------- A00_Common/line_stack_screen.ts | 19 ++-- A00_Common/line_stack_tex.ts | 9 +- A00_Common/line_stack_text.ts | 19 ++-- common_util/common_util_line_stack_pick.ts | 56 +++++++++++- resources/tester/helper_804_2_line_stack.cjs | 27 ++++++ .../tester/test_804_4_line_stack_take.py | 25 ++++++ 10 files changed, 219 insertions(+), 114 deletions(-) diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index c04b45aaf..63e5f3cb7 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -38,11 +38,25 @@ display: none; } -/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래 */ -@container (max-width: 1240px) { +/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 · fit()) */ +.lstk[data-edit="1"] .lstk__right { + position: sticky; + inset-block-start: 0; + max-height: var(--lstk-view, 100vh); + overflow: auto; +} + +/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래(고정 없음) */ +@container (max-width: 1040px) { .lstk__areas { grid-template-columns: minmax(0, 1fr); } + + .lstk[data-edit="1"] .lstk__right { + position: static; + max-height: none; + overflow: visible; + } } /* ── 왼쪽 편집 ── */ @@ -269,13 +283,9 @@ font-size: 1.05em; } -/* 짝 강조(679) — 왼쪽 카드 · 오른쪽 줄 같은 자리 */ +/* 짝 강조(679 · 684) — 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 = 같은 바탕 채우기 */ .lstk-line.is-pair, -.lstk-item.is-pair { - outline: 2px solid var(--color-accent); - outline-offset: -2px; -} - +.lstk-item.is-pair > .lstk-ihead, .lstk-out tr.is-pair > td { background: var(--color-accent-soft); } diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 0f7c60b3b..4e031a658 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -32,10 +32,15 @@ import { type StackItem, } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; -import { addPicked, repick } from "../common_util/common_util_line_stack_pick"; +import { + addPicked, + aliasText, + repick, + tableAlias, +} from "../common_util/common_util_line_stack_pick"; import { attachDrag } from "./line_stack_drag"; import { openStackPick, type StackTake } from "./line_stack_pick"; -import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows"; +import { buildBlock } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; export interface EditHooks { @@ -49,10 +54,8 @@ export interface EditHooks { name: (key: string) => string; /** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */ open: Set; - templates: () => Tpl[]; /** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */ pair: (loc: string) => void; - saveTpl: (tpl: Tpl) => void; /** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */ take?: StackTake; } @@ -112,10 +115,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { ]), ], }); + // 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감 + const alias = tableAlias(d); + const shown = (t: unknown): string => aliasText(String(t ?? ""), alias); + const real = (t: string): string => aliasText(t.trim(), alias, true); const qty = field( lt("Qty"), - line.수량 ?? "", - (t) => put((l) => (t.trim() ? (l.수량 = t.trim()) : delete l.수량)), + shown(line.수량), + (t) => put((l) => (t.trim() ? (l.수량 = real(t)) : delete l.수량)), "l", ); const price: HTMLElement[] = []; @@ -145,12 +152,13 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { pick.title = lt("Repick"); pick.dataset.repick = String(i); price.push(pick); - } else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l")); + } else + price.push(field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l")); const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭; // 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5) const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null; const read = (t: unknown): string => - String(t ?? "").replace(/번지\.([^.\s()]+)\./g, (all, p: string) => + shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) => places?.[p] ? `${places[p]} ` : all, ); return [ @@ -180,11 +188,6 @@ function addLine(h: EditHooks, at: number, kind: string): void { (p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)), h.take, ); - if (kind.startsWith("tpl:")) { - const t = h.templates().find((x) => x.id === kind.slice(4)); - if (t) h.change((d, items) => items[at].줄.push(insertTpl(d, t))); - return; - } h.change((d, items) => { const it = items[at]; const n = (d.금액 ??= []).length; @@ -228,9 +231,6 @@ export function buildEdit(h: EditHooks): HTMLElement { })), { value: "공식", text: lt("K_새공식") }, { value: "틀", text: lt("K_틀") }, - ...h - .templates() - .map((t) => ({ value: `tpl:${t.id}`, text: `${lt("K_공식")} — ${t.name}` })), { value: "글", text: lt("K_글") }, ], value: "", diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 2897a6de8..5f2dffa42 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -30,8 +30,8 @@ import { type StackDoc, } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; -import { stackLevels } from "../common_util/common_util_line_stack_pick"; -import { katexNode, symTex, tex, valTex } from "./line_stack_tex"; +import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick"; +import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex"; import { lt } from "./line_stack_text"; type Cols = Partial>; @@ -71,11 +71,11 @@ function exprNode( vals: Values, inline = false, cost?: string, - place?: Record, + more: Partial = {}, ) { const n = nodeOf(text); return n - ? katexNode(tex(n, { side, vals, inline, cost, place })) + ? katexNode(tex(n, { side, vals, inline, cost, ...more })) : el("span", { text: String(text ?? "") }); } const hasVar = (text: unknown): boolean => { @@ -103,7 +103,8 @@ function rows( const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만 const steps = lineMap(doc); const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5) - const unit = (id: string): string => vars.get(id)?.단위 ?? ""; + const unit = (id: string): string => shownUnit(vars.get(id)?.단위); + const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683) const subOf = (id: string): FormulaDoc | null => { const k = vars.get(id)?.연결; if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null; @@ -153,7 +154,7 @@ function rows( for (const id of ids.filter((x) => shown.has(x))) { const v = vals[id]; const t = lines.get(id); - const head = katexNode(symTex(id)); + const head = katexNode(symTex(alias[id] ?? id)); if (t !== undefined && hasVar(t)) { flush(); const res = isNum(v) @@ -161,8 +162,8 @@ function rows( : missing(a, id); // 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄) const n = nodeOf(t)!; - const sym = tex(n, { side: "sym", vals }); - const val = tex(n, { side: "val", vals }); + const sym = tex(n, { side: "sym", vals, alias }); + const val = tex(n, { side: "val", vals, alias }); const same = isNum(v) && val === valTex(v); push({ h: [ @@ -190,7 +191,10 @@ function rows( expand(id); continue; } - const expr = t !== undefined && !isNum(Number(t)) ? [" = ", exprNode(t, "sym", vals)] : []; + const expr = + t !== undefined && !isNum(Number(t)) + ? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })] + : []; const cell = el("span", { className: "lstk-pack", children: [ @@ -234,7 +238,11 @@ function rows( const qtyNode = (cost?: string): (string | HTMLElement)[] => qty === null ? [] - : [" × ", exprNode(qty, "val", scope, true, cost), line.단위 ? ` ${line.단위}` : ""]; + : [ + " × ", + exprNode(qty, "val", scope, true, cost, { alias }), + line.단위 ? ` ${line.단위}` : "", + ]; if (r.까닭 && r.금액 === null) { push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] }); return { sub: false, cols: {} }; @@ -264,11 +272,11 @@ function rows( const price = hasVar(line.단가) && kindOf(doc, i) === "율" ? [ - exprNode(line.단가, "sym", scope, true, undefined, place), + exprNode(line.단가, "sym", scope, true, undefined, { place, alias }), " = ", - exprNode(line.단가, "val", scope, true), + exprNode(line.단가, "val", scope, true, undefined, { alias }), ] - : [exprNode(line.단가, "val", scope, true)]; + : [exprNode(line.단가, "val", scope, true, undefined, { alias })]; const c = String(line.비목); push({ h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`], diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index f660738ca..37ca4a443 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -3,14 +3,14 @@ * 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장] * 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기] * 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결 - * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면 - * 「내 틀 (틀 이름 에서 변경)」 · 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과 — 작업량 등) + * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · + * 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」 * 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`) * ========================================================================== */ import { createButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; -import { askText, el, showToast } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { DocError, @@ -27,32 +27,25 @@ import { setRow, shapeOf, unfold, - type StackDoc, type StackItem, type StackLine, } from "../common_util/common_util_line_stack"; -import { boundConds, currentRow, pickCell } from "../common_util/common_util_line_stack_pick"; +import { + aliasText, + boundConds, + currentRow, + pickCell, + shownUnit, + tableAlias, +} from "../common_util/common_util_line_stack_pick"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import type { EditHooks } from "./line_stack_edit"; import { openTablePick } from "./line_stack_pick"; import { lt } from "./line_stack_text"; -/** 내 틀 — 기호 · 식 조각(이 브라우저) */ -export interface Tpl { - id: string; - name: string; - 문서: FormulaDoc; -} - const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; type Block = Extract; -/** 틀 넣기 — 새 공식 묶음 줄 */ -export function insertTpl(d: StackDoc, t: Tpl): Block { - const moved = unfold(d, t.문서); - return { 꼴: "공식", 기호: moved, 틀: `my:${t.id}`, 틀꼴: shapeOf(d, moved) }; -} - const LINK_TAG: Record = { 표: "표", 요소: "요소", @@ -81,9 +74,15 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v])); const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")])); const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block; + // 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감 + const alias = tableAlias(d); + const show = (id: string): string => alias[id] ?? id; + const shownText = (t: string): string => aliasText(t, alias); + const realText = (t: string): string => aliasText(t, alias, true); const valText = (id: string): string => { const v = vals[id]; - if (isNum(v) || typeof v === "string") return `${eqText(v)} ${vars.get(id)?.단위 ?? ""}`.trim(); + if (isNum(v) || typeof v === "string") + return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim(); if (v && typeof v === "object") return ["노무비", "재료비", "경비"] .map((c) => `${c} ${eqText((v as Record)[c])}`) @@ -93,12 +92,11 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM // ── 이름표 ── const tplName = (k: string): string => - k.startsWith("my:") - ? (h.templates().find((t) => `my:${t.id}` === k)?.name ?? lt("My_Tpl")) - : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); + k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685) const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴; + const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id)); const label = !line.틀 - ? lt("K_공식") + ? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식") : changed ? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})` : tplName(line.틀); @@ -116,28 +114,6 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM h.change(() => undefined); }, }), - createButton({ - label: lt("Save_Tpl"), - variant: "ghost", - size: 24, - onClick: () => - void askText({ message: lt("Tpl_Name"), value: label }).then((name) => { - if (!name?.trim()) return; - const ids = new Set(line.기호); - const id = `t${Date.now()}`; - h.saveTpl({ - id, - name: name.trim(), - 문서: { - 변수: clone((d.변수 ?? []).filter((v) => ids.has(varId(v)))), - 식: clone((d.식 ?? []).filter((l) => ids.has(String(l.기호 ?? "")))), - }, - }); - h.change((doc, items) => - Object.assign(blockOf(items), { 틀: `my:${id}`, 틀꼴: shapeOf(doc, line.기호) }), - ); - }), - }), ], }); head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : ""; @@ -246,16 +222,20 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM }), }), ); - const name = el("b", { className: "lstk-sym", text: id }); + const name = el("b", { className: "lstk-sym", text: show(id) }); const valueOf = expr ?? (link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : ""); let body: (HTMLElement | string)[]; if (open) body = [ - input(id, lt("Row_Name"), "lstk-f-s", (t) => renameRow(id, t)), + input(show(id), lt("Row_Name"), "lstk-f-s", (t) => + renameRow(id, t.trim() === show(id) ? id : t), + ), "=", - input(valueOf, lt("Row_Expr"), "lstk-f-l", (t) => h.change((doc) => setRow(doc, id, t))), + input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) => + h.change((doc) => setRow(doc, id, realText(t))), + ), input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) => h.change((doc) => { let x = doc.변수?.find((y) => varId(y) === id); @@ -274,15 +254,19 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM else if (expr !== undefined || sub || link?.갈래 === "단가산출") body = [ name, - `= ${expr ?? ""}${expr ? " =" : ""}`, + `= ${shownText(expr ?? "")}${expr ? " =" : ""}`, el("span", { className: "lstk-val", text: valText(id) }), ]; else body = [ name, "=", - input(valueOf, valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, t))), - el("span", { className: "lstk-unit", text: v?.단위 ?? "" }), + input(shownText(valueOf), valText(id), "lstk-f-m", (t) => + h.change((doc) => setRow(doc, id, realText(t))), + ), + ...(shownUnit(v?.단위) + ? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })] + : []), ]; const row = el("div", { className: `lstk-frow ${open ? "is-open" : ""}`, diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 11c7dcaf3..fd8041734 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -39,7 +39,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { buildEdit } from "./line_stack_edit"; import { buildOut } from "./line_stack_out"; -import type { Tpl } from "./line_stack_rows"; import { lt } from "./line_stack_text"; import "./formula_doc.css"; import "./line_stack.css"; @@ -66,7 +65,6 @@ interface Loaded { 판?: string; } -const TPL_KEY = "lineStack.myTemplates"; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; export function createStackScreen(opt: StackScreenOptions) { @@ -80,6 +78,13 @@ export function createStackScreen(opt: StackScreenOptions) { attrs: { "data-line-stack": "" }, children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot], }); + // 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css) + const fit = (): void => { + let p = root.parentElement; + while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement; + root.style.setProperty("--lstk-view", `${p?.clientHeight ?? innerHeight}px`); + }; + window.addEventListener("resize", fit); const cp = opt.copy; let key = ""; let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force) @@ -136,13 +141,6 @@ export function createStackScreen(opt: StackScreenOptions) { /* 캐시가 막혀도 화면 안 고침은 남음 */ } }; - const templates = (): Tpl[] => { - try { - return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]") as Tpl[]; - } catch { - return []; - } - }; // ── 셈 ── const usable = (): Record => { @@ -323,9 +321,7 @@ export function createStackScreen(opt: StackScreenOptions) { subDoc, name: (k) => cache.names.get(k) ?? "", open: blocks, - templates, pair: (loc) => pair(loc, "left"), - saveTpl: (t) => localStorage.setItem(TPL_KEY, JSON.stringify([...templates(), t])), take: takeCtx(), }), ] @@ -348,6 +344,7 @@ export function createStackScreen(opt: StackScreenOptions) { ); foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)])); pair(paired); + fit(); }; const take = (got: Loaded): void => { diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts index e3fe96c68..cc20596de 100644 --- a/A00_Common/line_stack_tex.ts +++ b/A00_Common/line_stack_tex.ts @@ -21,6 +21,8 @@ export interface TexCtx { cost?: string; /** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */ place?: Record; + /** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */ + alias?: Record; } const HIDE = new Set(["반올림", "올림", "버림"]); @@ -90,12 +92,11 @@ export function tex(n: Node, c: TexCtx): string { case "str": return txt(`'${n[1]}'`); case "var": { - if (c.side === "sym") return symTex(n[1]); + const id = c.alias?.[n[1]] ?? n[1]; + if (c.side === "sym") return symTex(id); let v = c.vals[n[1]]; if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost]; - return v === null || v === undefined || typeof v === "object" - ? miss(symTex(n[1])) - : valTex(v); + return v === null || v === undefined || typeof v === "object" ? miss(symTex(id)) : valTex(v); } case "field": { const [, of, key] = n; diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 91172d2b9..691be5295 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -11,18 +11,18 @@ const TEXT = { Total: ["합 계", "Total"], Sum: ["계", "Sum"], Sub_Sum: ["소 계", "Subtotal"], - Add_Item: ["+ 항목", "+ Item"], + Add_Item: ["+ 그룹", "+ Group"], Add_Line: ["+ 줄", "+ Line"], - Del_Item: ["항목 지움", "Delete item"], + Del_Item: ["그룹 지움", "Delete group"], Del: ["지움", "Delete"], - Item: ["항목", "Item"], + Item: ["그룹", "Group"], K_줄: ["줄", "Line"], Drag: ["끌어 옮기기", "Drag to move"], - Fold_Item: ["항목 접기", "Collapse item"], - Unfold_Item: ["항목 펼치기", "Expand item"], + Fold_Item: ["그룹 접기", "Collapse group"], + Unfold_Item: ["그룹 펼치기", "Expand group"], Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"], Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"], - Title: ["항목 이름(비우면 번호 없음)", "Item title"], + Title: ["그룹 이름(비우면 번호 없음)", "Group title"], Gana: ["가나다", "Sub"], K_인력: ["인력", "Labor"], K_재료: ["재료", "Material"], @@ -33,6 +33,7 @@ const TEXT = { Take_Failed: ["복사 못 함", "Copy failed"], K_율: ["율 · 식", "Rate"], K_공식: ["공식", "Formula"], + K_표값: ["표 값", "Table values"], K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"], K_틀: ["공식 — 틀 찾기(단가산출 결과)", "Formula — template"], K_글: ["글 · 근거", "Text"], @@ -45,10 +46,8 @@ const TEXT = { Find: ["찾기", "Find"], Pick: ["고름 ▾", "Pick ▾"], None_Found: ["찾은 것 없음", "Nothing found"], - Unfold: ["식으로 풀기", "Unfold"], + Unfold: ["식 고치기", "Edit formula"], Fold: ["접기", "Fold"], - Save_Tpl: ["틀로 저장", "Save as template"], - Tpl_Name: ["틀 이름", "Template name"], New_Formula: ["새 공식", "New formula"], My_Formula: ["내 공식", "My formula"], My_Tpl: ["내 틀", "My template"], @@ -58,7 +57,7 @@ const TEXT = { Row_Expr: ["값 또는 식", "Value or expr"], Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"], Empty_Item: ["줄 없음 — [+ 줄]", "No lines — [+ Line]"], - Empty: ["빈 단가산출 — [+ 항목] 으로 시작", "Empty — start with [+ Item]"], + Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"], Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"], Notes: [ "줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)", diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts index 02cc7ad4a..e29e7d67d 100644 --- a/common_util/common_util_line_stack_pick.ts +++ b/common_util/common_util_line_stack_pick.ts @@ -6,7 +6,15 @@ * 산출근거 수준 = 항목(1. 2.) 1 · 가나다 2 · 줄 = 항목 + 1 · 제목 없는 항목 줄 = 1 * ========================================================================== */ -import { names, varId, type FormulaDoc, type Link } from "./common_util_formula_doc"; +import { + names, + renameText, + splitId, + symId, + varId, + type FormulaDoc, + type Link, +} from "./common_util_formula_doc"; import { findRow, type DocTable } from "./common_util_formula_doc_table"; import { evalNode } from "./common_util_formula_doc_view"; import { @@ -14,6 +22,7 @@ import { isOption, newVar, nodeOf, + symbols, type MoneyKind, type StackItem, } from "./common_util_line_stack"; @@ -144,3 +153,48 @@ export function stackLevels(items: StackItem[]): { item: number; line: number }[ return { item, line: item + 1 }; }); } + +/** 표 값 기호 화면 이름(804-5 · 요구 683) — 「Q_수량2^표」 → 「Q_작업반장」(고른 줄 글 조건 첫 낱말) · 글 조건 없으면 「^표」만 뗌 + * · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) */ +export function tableAlias(doc: FormulaDoc): Record { + const taken = new Set([ + ...symbols(doc), + ...(doc.금액 ?? []).map((l) => String(l.기호 ?? "")), + "노무비", + "재료비", + "경비", + ]); + const out: Record = {}; + for (const v of doc.변수 ?? []) { + const link = v.연결; + if (link?.갈래 !== "표") continue; + const id = varId(v); + const [s, lo, up] = splitId(id); + const said = Object.values(link.조건 ?? {}) + .map(String) + .find((t) => /^'.+'$/.test(t)); + const label = said?.slice(1, -1).split(/[\s(,·/]/)[0] ?? ""; + if (up !== "표") continue; // 「^표」 꼬리 기호만(r_인력 등 손 이름은 그대로) + const base = label || lo.replace(/\d+$/, "") || lo; // 번호 꼬리 뗌(겹치면 다시 2 · 3) + for (let n = 1; n < 100; n++) { + const now = symId(s, n > 1 ? `${base}${n}` : base); + const node = nodeOf(now); + if (taken.has(now) || node?.[0] !== "var" || node[1] !== now) continue; + taken.add(now); + out[id] = now; + break; + } + } + return out; +} + +/** 식 글의 기호를 화면 이름으로(또는 `back` = 화면 이름을 기호로) */ +export function aliasText(text: string, alias: Record, back = false): string { + let t = text; + for (const [id, now] of Object.entries(alias)) + t = String(back ? renameText(t, now, id) : renameText(t, id, now)); + return t; +} + +/** 표 칸 단위 「줄 따름」(줄마다 다른 단위) = 화면에 안 보임(요구 683) */ +export const shownUnit = (u: unknown): string => (u === "줄 따름" ? "" : String(u ?? "")); diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index f58f610f7..83db307ca 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -213,6 +213,32 @@ function moveAll({ 문서, 옮김 }) { } const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서)); +/* 683 표 값 기호 화면 이름 — 별칭 · 글 왕복(화면 이름 → 기호 = 처음 식과 같은 나무) */ +const pk2 = util("common_util_line_stack_pick"); +const fd = util("common_util_formula_doc"); +function aliasOf({ 문서 }) { + const alias = pk2.tableAlias(문서); + const texts = [ + ...(문서.식 || []).map((l) => l.식), + ...(문서.금액 || []).flatMap((l) => [l.수량, l.단가]), + ].filter((t) => typeof t === "string" && t); + const bad = []; + for (const t of texts) { + let want; + try { + want = fd.textOf(fd.parse(t)); + } catch { + continue; + } + const shown = pk2.aliasText(t, alias); + const back = pk2.aliasText(shown, alias, true); + if (fd.textOf(fd.parse(back)) !== want) bad.push(t); + if (Object.keys(alias).some((id) => fd.names(fd.parse(shown)).includes(id))) + bad.push(`남음:${t}`); + } + return { 별칭: alias, 틀림: bad }; +} + const input = JSON.parse(fs.readFileSync(0, "utf8")); const job = { 왕복: roundTrip, @@ -224,5 +250,6 @@ const job = { 복사: takeIn, 옮김: moveAll, 번지: placeOf, + 별칭: aliasOf, }[input.할일]; process.stdout.write(JSON.stringify(input.문서들.map(job))); diff --git a/resources/tester/test_804_4_line_stack_take.py b/resources/tester/test_804_4_line_stack_take.py index df830adca..828d2d23e 100644 --- a/resources/tester/test_804_4_line_stack_take.py +++ b/resources/tester/test_804_4_line_stack_take.py @@ -230,3 +230,28 @@ def test_끌어옮김_번지이름(world): places = {m["번지"] for m in g["문서"]["금액"] if m.get("번지")} assert places and set(names) == places assert all(v and not v.startswith("복사") for v in names.values()), names + + +def test_표값_화면이름_전수(): + """요구 683 — 표 값 기호(「Q_수량2^표」) = 화면 이름(「Q_작업반장」) · 겹침 없음 · 식 글 왕복 = 같은 식(1,518 전수).""" + docs = dc.folder() + keys = sorted(docs) + got = _ts("별칭", [{"문서": docs[k]} for k in keys]) + named = 0 + for k, g in zip(keys, got): + assert not g["틀림"], (k, g["틀림"][:3]) + alias = g["별칭"] + assert len(set(alias.values())) == len(alias), k # 겹치면 2 · 3 + assert all("^표" not in v and k.endswith("^표") for k, v in alias.items()), k + named += len(alias) + assert named > 500 + gc = dict(zip(keys, got))[FORM]["별칭"] + assert gc and "Q_보통인부22" not in gc.values() # 번호 꼬리 뗀 뒤 2 · 3 + # 문서 기호는 그대로(화면만) — 원본 문서 변수에 별칭 이름 없음 + names = { + v["기호"] + + (f"_{v['아래']}" if v.get("아래") else "") + + (f"^{v['위']}" if v.get("위") else "") + for v in docs[FORM]["변수"] + } + assert not set(gc.values()) & names