From af576e5a7ae7025795c0152a3e5e7ea0e14d36b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 08:29:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(804-5):=20=EC=A4=84=20=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EC=99=BC=EC=AA=BD=20=EC=86=90=EC=A7=88=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=90=EC=9B=90=20=EC=9D=B4=EB=A6=84=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EB=9D=A0=20=C2=B7=20=EB=81=8C?= =?UTF-8?q?=EC=96=B4=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=C2=B7=20X=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A8=20=EB=B2=88=EC=A7=80=20=EC=9D=BD=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음 - 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기 - 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음 - 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함) - 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리 - 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠) - 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack.css | 97 +++++++-- A00_Common/line_stack_drag.ts | 80 +++++++ A00_Common/line_stack_edit.ts | 201 ++++++++++++++---- A00_Common/line_stack_out.ts | 39 +++- A00_Common/line_stack_tex.ts | 18 +- A00_Common/line_stack_text.ts | 9 +- common_util/common_util_line_stack.ts | 13 ++ common_util/common_util_line_stack_copy.ts | 26 +++ resources/tester/helper_804_2_line_stack.cjs | 25 +++ .../tester/test_804_4_line_stack_take.py | 30 +++ ui_template/ui_template_toast.ts | 7 +- 11 files changed, 475 insertions(+), 70 deletions(-) create mode 100644 A00_Common/line_stack_drag.ts diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index e5ff8fa15..c04b45aaf 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -49,16 +49,49 @@ .lstk-edit { display: flex; flex-direction: column; - gap: var(--spacing-8); + gap: var(--spacing-16); } +/* 항목(804-5) — 머리 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 = 한 단 더 들여씀 */ .lstk-item { display: flex; flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); + gap: var(--spacing-8); +} + +.lstk-item.is-sub { + margin-inline-start: var(--spacing-24); +} + +.lstk-item + .lstk-item.is-sub { + margin-block-start: calc(-1 * var(--spacing-8)); +} + +.lstk-ihead { + align-items: center; + padding: var(--spacing-4) var(--spacing-8); + border-inline-start: 4px solid var(--color-accent); border-radius: var(--radius-sm); + background: var(--color-surface-sunken); +} + +.lstk-item.is-sub > .lstk-ihead { + border-inline-start-color: var(--color-border-strong); +} + +.lstk-no { + min-width: 1.5em; + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.lstk-lines { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-inline-start: var(--spacing-12); + padding-inline-start: var(--spacing-12); + border-inline-start: 2px solid var(--color-border-strong); } .lstk-ihead, @@ -84,9 +117,15 @@ flex-direction: column; gap: var(--spacing-4); padding: var(--spacing-8); - padding-inline-end: 120px; + padding-inline: var(--spacing-24) var(--spacing-32); + border: 1px solid var(--color-border); border-radius: var(--radius-sm); - background: var(--color-surface-sunken); + background: var(--color-surface-raised); +} + +/* 글 · 근거 칸 = 한 줄 높이(세로 묶음에서 늘지 않게) */ +.lstk-line > .lstk-f-l { + flex: none; } .lstk-ltools { @@ -96,6 +135,45 @@ gap: 0; } +/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */ +.lstk-grip { + touch-action: none; +} + +.lstk-line > .lstk-grip { + position: absolute; + inset-block-start: var(--spacing-12); + inset-inline-start: var(--spacing-8); +} + +.lstk-edit[data-dragging] { + cursor: grabbing; + user-select: none; +} + +.is-dragging { + opacity: 0.45; +} + +.lstk-line[data-drop="before"], +.lstk-item[data-drop="before"] { + box-shadow: 0 -3px 0 0 var(--color-accent); +} + +.lstk-line[data-drop="after"], +.lstk-item[data-drop="after"] { + box-shadow: 0 3px 0 0 var(--color-accent); +} + +.lstk-item[data-drop="in"] .lstk-empty { + outline: 2px dashed var(--color-accent); + outline-offset: 2px; +} + +.lstk-res { + max-width: 100%; +} + .lstk-kind { font-size: var(--text-caption); font-weight: var(--font-weight-medium); @@ -126,16 +204,11 @@ .lstk-val, .lstk-unit, .lstk-tag, -.lstk-hint, -.lstk-link { +.lstk-hint { font-size: var(--text-body-sm); color: var(--color-text-secondary); } -.lstk-link { - color: var(--color-text-body); -} - .lstk-tag { padding: 0 var(--spacing-4); border: 1px solid var(--color-border); diff --git a/A00_Common/line_stack_drag.ts b/A00_Common/line_stack_drag.ts new file mode 100644 index 000000000..9f32f3ae8 --- /dev/null +++ b/A00_Common/line_stack_drag.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * line_stack_drag.ts + * 줄 쌓기 왼쪽 끌어 옮기기(PLAN 804-5 · 요구 682-4) — 손잡이(⋮⋮)를 누르고 끌면 놓을 자리에 선 표시 · 놓으면 `drop` + * 줄 = 항목 안 · 항목 사이 어디든(빈 항목 = 그 안) → `k.p`(항목 k 의 p 자리) · 항목 = 항목 사이 → `p` + * 포인터 끌기(마우스 · 터치 같음) · Esc = 그만 · 자리 표시 = 클래스(`data-drop`)만 — 끄는 동안 배치 안 바뀜 + * ========================================================================== */ + +export interface DragOpts { + /** 끄는 것 — 줄 카드 · 항목 상자 */ + what: "line" | "item"; + /** 끌기 영역(`.lstk-edit`) */ + root: HTMLElement; + /** 끄는 요소(반투명) */ + self: HTMLElement; + /** 놓음 — line `k.p` · item `p` */ + drop: (to: string) => void; +} + +const mid = (r: DOMRect): number => r.top + r.height / 2; + +/** 포인터 y 의 놓을 자리 + 표시할 요소 · 꼴 */ +function spotOf(o: DragOpts, y: number): { to: string; at: HTMLElement; side: string } | null { + const boxes = [...o.root.querySelectorAll(":scope > .lstk-item")]; + if (!boxes.length) return null; + if (o.what === "item") { + const k = boxes.findIndex((b) => y < mid(b.getBoundingClientRect())); + return k < 0 + ? { to: String(boxes.length), at: boxes[boxes.length - 1], side: "after" } + : { to: String(k), at: boxes[k], side: "before" }; + } + // 줄 — 포인터가 든 항목(위 · 아래 밖 = 처음 · 끝 항목) + let k = boxes.findIndex((b) => y < b.getBoundingClientRect().bottom); + if (k < 0) k = boxes.length - 1; + const cards = [...boxes[k].querySelectorAll(":scope .lstk-lines > .lstk-line")]; + if (!cards.length) return { to: `${k}.0`, at: boxes[k], side: "in" }; + const p = cards.findIndex((c) => y < mid(c.getBoundingClientRect())); + return p < 0 + ? { to: `${k}.${cards.length}`, at: cards[cards.length - 1], side: "after" } + : { to: `${k}.${p}`, at: cards[p], side: "before" }; +} + +/** 손잡이에 끌기를 붙임 */ +export function attachDrag(grip: HTMLElement, o: DragOpts): void { + grip.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + grip.setPointerCapture(e.pointerId); + let spot: ReturnType = null; + const clear = (): void => { + for (const x of o.root.querySelectorAll("[data-drop]")) x.removeAttribute("data-drop"); + }; + o.self.classList.add("is-dragging"); + o.root.dataset.dragging = o.what; + const move = (ev: PointerEvent): void => { + clear(); + spot = spotOf(o, ev.clientY); + if (spot) spot.at.dataset.drop = spot.side; + }; + const end = (ok: boolean): void => { + grip.removeEventListener("pointermove", move); + grip.removeEventListener("pointerup", up); + grip.removeEventListener("pointercancel", cancel); + window.removeEventListener("keydown", esc); + clear(); + o.self.classList.remove("is-dragging"); + delete o.root.dataset.dragging; + if (ok && spot) o.drop(spot.to); + }; + const up = (): void => end(true); + const cancel = (): void => end(false); + const esc = (ev: KeyboardEvent): void => { + if (ev.key === "Escape") end(false); + }; + grip.addEventListener("pointermove", move); + grip.addEventListener("pointerup", up); + grip.addEventListener("pointercancel", cancel); + window.addEventListener("keydown", esc); + }); +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index ac9904e5e..0f7c60b3b 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -1,31 +1,39 @@ /* ============================================================================= * line_stack_edit.ts - * 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(제목 · 가나다 · [+ 줄] · 지움) 안 줄 카드(위 · 아래 · 지움) + * 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X) + * 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) · + * X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈) * 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식) * · 공식 묶음(`line_stack_rows.ts`) · 글 줄 * 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때) * 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = - * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = [찾아 바꾸기] + * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추 * ========================================================================== */ -import { createButton } from "@ui/ui_template_button"; +import { createButton, createIconButton } from "@ui/ui_template_button"; import { createCheckbox } from "@ui/ui_template_checkbox"; -import { el } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc"; import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { dropSyms, + docOf, freshId, kindOf, + moveItem, + moveLine, newVar, ownedOf, + stackOf, type MoneyKind, type StackDoc, 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 { attachDrag } from "./line_stack_drag"; import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; @@ -65,8 +73,8 @@ const field = ( f.input.addEventListener("change", () => set(f.input.value)); return f.root; }; -const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") => - createButton({ label, variant, size: 24, onClick }); +const btn = (label: string, onClick: () => void) => + createButton({ label, variant: "ghost", size: 24, onClick }); const KIND_KEY: Record = { 인력: "K_인력", @@ -124,18 +132,42 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { : mat ? String(mat.검색어 ?? mat.이름 ?? mat.상세구분 ?? "") : ""; - price.push( - el("span", { className: "lstk-link", text: label || "—" }), - btn(lt("Repick"), () => - openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))), - ), - ); + const size = (line as { 규격?: string }).규격; + const pick = createButton({ + label: (size && !label.includes(size) ? `${label} ${size}` : label) || "—", + variant: "ghost", + tone: "link", + size: 24, + iconStart: "search", + onClick: () => openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))), + }); + pick.classList.add("lstk-res"); + 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")); 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) => + places?.[p] ? `${places[p]} ` : all, + ); return [ el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }), head, - el("div", { className: "lstk-row", children: [qty, el("span", { text: "×" }), ...price] }), + el("div", { + className: "lstk-row", + children: [qty, el("span", { className: "lstk-unit", text: "×" }), ...price], + }), + ...(places + ? [ + el("div", { + className: "lstk-hint", + text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`, + }), + ] + : []), ...(why ? [el("div", { className: "lstk-miss", text: why })] : []), ]; } @@ -175,15 +207,6 @@ function addLine(h: EditHooks, at: number, kind: string): void { }); } -/** 줄 옮김 — 항목 끝을 넘으면 옆 항목으로 */ -function move(items: StackItem[], i: number, j: number, by: -1 | 1): void { - const [line] = items[i].줄.splice(j, 1); - const to = j + by; - if (to < 0 && i > 0) items[i - 1].줄.push(line); - else if (to > items[i].줄.length && i < items.length - 1) items[i + 1].줄.unshift(line); - else items[i].줄.splice(Math.max(0, Math.min(to, items[i].줄.length)), 0, line); -} - /** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */ function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void { const line = items[i].줄[j]; @@ -221,10 +244,67 @@ export function buildEdit(h: EditHooks): HTMLElement { }); return pick.root; }; + // X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만) + const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => { + const snap = JSON.stringify(h.doc); + let after = ""; + h.change((d, items) => { + fn(d, items); + after = JSON.stringify(docOf(d, items)); + }); + showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () => + h.change((d, items) => { + if (JSON.stringify(docOf(d, items)) !== after) + return void showToast(lt("Undo_Late"), "warning"); + const back = JSON.parse(snap) as StackDoc; + for (const k of Object.keys(d)) delete (d as Record)[k]; + Object.assign(d, back); + items.splice(0, items.length, ...stackOf(back)); + }), + ); + }; + const x = (title: string, onClick: () => void): HTMLElement => { + const b = createIconButton({ icon: "x", title, size: 24, onClick }); + b.classList.add("lstk-x"); + return b; + }; + const grip = (): HTMLElement => + el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } }); + const root = el("div", { className: "lstk-edit" }); + /** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */ + const refold = (fn: (flags: boolean[]) => void): void => { + const flags = h.items.map((_, k) => h.open.has(`fold:${k}`)); + fn(flags); + h.items.forEach((_, k) => h.open.delete(`fold:${k}`)); + flags.forEach((f, k) => f && h.open.add(`fold:${k}`)); + }; + // 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 가나다 = 가. 나.) + let n = 0; + let sub = 0; + const nos = h.items.map((it) => { + if (!it.제목.trim()) return ""; + const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`; + if (!it.가나다) sub = 0; + return no; + }); const items = h.items.map((it, i) => { + const folded = h.open.has(`fold:${i}`); + const itemGrip = grip(); const head = el("div", { className: "lstk-ihead", children: [ + itemGrip, + createIconButton({ + icon: folded ? "chevronRight" : "chevronDown", + title: folded ? lt("Unfold_Item") : lt("Fold_Item"), + size: 24, + onClick: () => { + if (folded) h.open.delete(`fold:${i}`); + else h.open.add(`fold:${i}`); + h.change(() => undefined); + }, + }), + el("b", { className: "lstk-no", text: nos[i] }), field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"), createCheckbox(lt("Gana"), { checked: !!it.가나다, @@ -232,25 +312,29 @@ export function buildEdit(h: EditHooks): HTMLElement { onChange: (c) => h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)), }).root, + ...(folded + ? [el("span", { className: "lstk-hint", text: `${lt("K_줄")} ${it.줄.length}` })] + : []), add(i), - btn( - lt("Del_Item"), - () => - h.change((d, items) => { - for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j); - items.splice(i, 1); - }), - "danger-ghost", + x(lt("Del_Item"), () => + drop(it.제목.trim() || lt("Item"), (d, items) => { + refold((f) => f.splice(i, 1)); + for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j); + items.splice(i, 1); + }), ), ], }); const lines = it.줄.map((line, j) => { + const lineGrip = grip(); const tools = el("div", { className: "lstk-ltools", children: [ - btn("↑", () => h.change((_, items) => move(items, i, j, -1))), - btn("↓", () => h.change((_, items) => move(items, i, j, 1))), - btn(lt("Del"), () => h.change((d, items) => dropLine(d, items, i, j)), "danger-ghost"), + x(lt("Del"), () => + drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) => + dropLine(d, items, i, j), + ), + ), ], }); const body = @@ -266,29 +350,56 @@ export function buildEdit(h: EditHooks): HTMLElement { ), ] : buildBlock(h, i, j, line); - const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] }); + const card = el("div", { + className: `lstk-line k-${line.꼴}`, + children: [lineGrip, tools, ...body], + }); card.dataset.loc = `${i}.${j}`; card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`)); + attachDrag(lineGrip, { + what: "line", + root, + self: card, + drop: (to) => { + const [k, p] = to.split(".").map(Number); + h.change((_, items) => moveLine(items, i, j, k, p)); + }, + }); return card; }); const box = el("div", { - className: "lstk-item", + className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`, attrs: { "data-loc": String(i) }, children: [ head, - ...(lines.length - ? lines - : [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]), + ...(folded + ? [] + : [ + el("div", { + className: "lstk-lines", + children: lines.length + ? lines + : [el("div", { className: "lstk-empty", text: lt("Empty_Item") })], + }), + ]), ], }); head.addEventListener("pointerdown", () => h.pair(String(i))); + attachDrag(itemGrip, { + what: "item", + root, + self: box, + drop: (to) => { + const p = Number(to); + refold((f) => moveItem(f, i, p)); + h.change((_, items) => moveItem(items, i, p)); + }, + }); return box; }); - return el("div", { - className: "lstk-edit", - children: [ - ...items, - btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))), - ], - }); + root.append( + ...items, + btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))), + ); + return root; } diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 6bb9b8428..2897a6de8 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -29,6 +29,7 @@ import { subInputs, 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 { lt } from "./line_stack_text"; @@ -61,12 +62,20 @@ const won = (n: number): string => eqText(Math.round(n)); const one = (n: number): string => n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const MAX_DEPTH = 3; +const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」) /** 식 글 → 조판(못 풀면 글) */ -function exprNode(text: unknown, side: "sym" | "val", vals: Values, inline = false, cost?: string) { +function exprNode( + text: unknown, + side: "sym" | "val", + vals: Values, + inline = false, + cost?: string, + place?: Record, +) { const n = nodeOf(text); return n - ? katexNode(tex(n, { side, vals, inline, cost })) + ? katexNode(tex(n, { side, vals, inline, cost, place })) : el("span", { text: String(text ?? "") }); } const hasVar = (text: unknown): boolean => { @@ -93,6 +102,7 @@ function rows( void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) }); 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 subOf = (id: string): FormulaDoc | null => { const k = vars.get(id)?.연결; @@ -206,9 +216,18 @@ function rows( 단가?: unknown; }; const scope: Values = { ...vals, ...moneyScope(a.줄, i) }; + const at: Record = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5) money.slice(0, i).forEach((m, k) => { - if (m.기호) scope[m.기호] = (a.줄?.[k] as { 금액?: unknown } | undefined)?.금액; // 앞 금액 줄 기호 = 그 줄 금액 + const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined; + if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액 + if (m.번지) + for (const c of COSTS) { + const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 }); + if (isNum(got?.비목?.[c])) + box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6; + } }); + if (Object.keys(at).length) scope.번지 = at; const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" "); const qty = line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null; @@ -244,7 +263,11 @@ function rows( const amt = r.금액 ?? 0; const price = hasVar(line.단가) && kindOf(doc, i) === "율" - ? [exprNode(line.단가, "sym", scope, true), " = ", exprNode(line.단가, "val", scope, true)] + ? [ + exprNode(line.단가, "sym", scope, true, undefined, place), + " = ", + exprNode(line.단가, "val", scope, true), + ] : [exprNode(line.단가, "val", scope, true)]; const c = String(line.비목); push({ @@ -256,6 +279,7 @@ function rows( const items = stackOf(doc); const terms: Record = { 노무비: [], 재료비: [], 경비: [] }; + const perItem: Record = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5) let n = 0; let sub = 0; const levels = stackLevels(items); @@ -291,7 +315,10 @@ function rows( const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10; push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" }); } - for (const c of COSTS) terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c])); + for (const c of COSTS) { + terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c])); + if (sum[c]) perItem[c].push(sum[c]); + } } lv = base + 1; loc = undefined; @@ -310,7 +337,7 @@ function rows( push({ h: [`${++n}. ${lt("Total")}`], cls: "item" }); lv = base + 2; for (const c of COSTS) { - const t = terms[c]; + const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c]; const v = a[c]; const body = !isNum(v) ? "—" diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts index 806e057b3..e3fe96c68 100644 --- a/A00_Common/line_stack_tex.ts +++ b/A00_Common/line_stack_tex.ts @@ -19,6 +19,8 @@ export interface TexCtx { inline?: boolean; /** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */ cost?: string; + /** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */ + place?: Record; } const HIDE = new Set(["반올림", "올림", "버림"]); @@ -69,6 +71,14 @@ export const valTex = (v: unknown): string => { return isNum(v) ? t.replace(/,/g, "{,}") : txt(t); }; +/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */ +const look = (n: Node, vals: Values): unknown => { + if (n[0] === "var") return vals[n[1]]; + if (n[0] !== "field") return undefined; + const v = look(n[1], vals); + return v && typeof v === "object" ? (v as Values)[n[2]] : undefined; +}; + const paren = (t: string): string => `\\left(${t}\\right)`; const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`; @@ -88,11 +98,13 @@ export function tex(n: Node, c: TexCtx): string { : valTex(v); } case "field": { - if (c.side === "val" && n[1][0] === "var") { - const v = c.vals[n[1][1]]; - const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined; + const [, of, key] = n; + if (c.side === "val") { + const got = look(n, c.vals); if (isNum(got)) return valTex(got); } + if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]]) + return txt(`${c.place[of[2]]} ${key}`); return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`; } case "neg": diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index e791e236d..91172d2b9 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -15,8 +15,13 @@ const TEXT = { Add_Line: ["+ 줄", "+ Line"], Del_Item: ["항목 지움", "Delete item"], Del: ["지움", "Delete"], - Up: ["위로", "Up"], - Down: ["아래로", "Down"], + Item: ["항목", "Item"], + K_줄: ["줄", "Line"], + Drag: ["끌어 옮기기", "Drag to move"], + Fold_Item: ["항목 접기", "Collapse item"], + Unfold_Item: ["항목 펼치기", "Expand item"], + Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"], + Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"], Title: ["항목 이름(비우면 번호 없음)", "Item title"], Gana: ["가나다", "Sub"], K_인력: ["인력", "Labor"], diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index 107d19070..f2bf87b14 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -318,6 +318,19 @@ export function dropSyms(doc: FormulaDoc, ids: string[]): void { doc.식 = (doc.식 ?? []).filter((l) => !gone.has(String(l.기호 ?? ""))); } +/** 끌어 옮김(804-5) — 줄 `[i, j]` → 항목 k 의 p 자리(빼기 전 차례 · 같은 항목 아래로면 하나 당김) */ +export function moveLine(items: StackItem[], i: number, j: number, k: number, p: number): void { + const [line] = items[i].줄.splice(j, 1); + if (!line || !items[k]) return void (line && items[i].줄.splice(j, 0, line)); + items[k].줄.splice(Math.min(i === k && p > j ? p - 1 : p, items[k].줄.length), 0, line); +} + +/** 끌어 옮김(804-5) — 항목 i → p 자리(빼기 전 차례) */ +export function moveItem(items: T[], i: number, p: number): void { + const [it] = items.splice(i, 1); + if (it !== undefined) items.splice(Math.min(p > i ? p - 1 : p, items.length), 0, it); +} + /** 공식 줄 값 · 식 고침 — 수 = 숫자 연결 · 식 = 식 줄 */ export function setRow(doc: FormulaDoc, id: string, text: string): void { const t = text.trim().replace(/,/g, ""); diff --git a/common_util/common_util_line_stack_copy.ts b/common_util/common_util_line_stack_copy.ts index c883d2bb2..f8e5aa5ca 100644 --- a/common_util/common_util_line_stack_copy.ts +++ b/common_util/common_util_line_stack_copy.ts @@ -195,3 +195,29 @@ export function addTaken(doc: StackDoc, items: StackItem[], t: Taken): void { for (const r of rates) pushMoney({ ...r, 단가: rescope(r.단가, places) as string }); } } + +/** 번지 읽는 이름(804-5) — 산출근거 「번지.복사N.노무비」 대신 · 그 번지 줄만 든 항목 제목 · 없으면 줄 이름(둘까지 · 「외 n」) */ +export function placeNames(doc: StackDoc, items: StackItem[]): Record { + const money = doc.금액 ?? []; + const out: Record = {}; + for (const p of new Set(money.map((l) => String(l.번지 ?? "")).filter(Boolean))) { + const mine = (l: StackLine): boolean => l.꼴 === "금액" && money[l.i]?.번지 === p; + const holds = items.filter((it) => it.줄.some(mine)); + const only = holds.every((it) => + it.줄.every((l) => l.꼴 !== "금액" || mine(l) || !money[l.i]?.번지), + ); + let title = only ? (holds.find((it) => it.제목.trim())?.제목.trim() ?? "") : ""; + // 여러 항목에 걸친 조각 = 첫 항목 「조각 이름 · 항목 제목」의 조각 이름(addTaken 꼴) + // ponytail: 항목 제목에 「 · 」가 있으면 덜 자름 — 조각 이름을 문서에 따로 적게 되면 그것으로 + if (title && holds.length > 1 && title.lastIndexOf(" · ") > 0) + title = title.slice(0, title.lastIndexOf(" · ")); + const lines = money.filter((l) => l.번지 === p).map((l) => String(l.이름 ?? "").trim()); + out[p] = + title || + (lines.length > 2 + ? `${lines[0]} 외 ${lines.length - 1}` + : lines.filter(Boolean).join(" · ")) || + p; + } + return out; +} diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index 91b8c7d28..f58f610f7 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -190,6 +190,29 @@ function takeIn({ 문서, 받음 }) { }; } +/* 804-5 끌어 옮김 · 번지 읽는 이름 — 옮김 = [[from, to] …](줄 "i.j" → "k.p" · 항목 "i" → "p") */ +function moveAll({ 문서, 옮김 }) { + let doc = JSON.parse(JSON.stringify(문서)); + for (const [a, b] of 옮김) { + const items = ls.stackOf(doc); + if (a.includes(".")) + ls.moveLine(items, ...a.split(".").map(Number), ...b.split(".").map(Number)); + else ls.moveItem(items, Number(a), Number(b)); + doc = ls.docOf(doc, items); + } + const items = ls.stackOf(doc); + return { + 문서: doc, + 항목: items.map((it) => ({ + 제목: it.제목, + 줄: it.줄.map((l) => + l.꼴 === "금액" ? doc.금액[l.i].이름 : l.꼴 === "글" ? l.글 : l.기호.join(","), + ), + })), + }; +} +const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서)); + const input = JSON.parse(fs.readFileSync(0, "utf8")); const job = { 왕복: roundTrip, @@ -199,5 +222,7 @@ const job = { 넣기: putPicked, 바꿈: repickOne, 복사: takeIn, + 옮김: moveAll, + 번지: placeOf, }[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 af2dc1d92..df830adca 100644 --- a/resources/tester/test_804_4_line_stack_take.py +++ b/resources/tester/test_804_4_line_stack_take.py @@ -200,3 +200,33 @@ def test_권한_저장_단가갱신(world, monkeypatch): got["후"]["계"] > again["셈"]["계"] + 1000 * 0.3 ) # 보통인부 줄들(복사한 0.3 인 포함) 따라감 assert got["문서"]["금액"] == again["문서"]["금액"] + + +def test_끌어옮김_번지이름(world): + """804-5 — 줄 끌기(항목 안 · 사이) · 항목 끌기 = 되돌려 끌면 같은 문서 · 율 줄 번지 = 읽는 이름.""" + gc = dc.folder()[DIG] # 항목 둘(인력 · 굴착기) + one = _ts("옮김", [{"문서": gc, "옮김": []}])[0] + first = one["항목"][0]["줄"][0] + gone = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"]]}])[0] + assert ( + gone["항목"][1]["줄"][0] == first + and len(gone["항목"][0]["줄"]) == len(one["항목"][0]["줄"]) - 1 + ) + back = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"], ["1.0", "0.0"]]}])[0] + assert back["문서"] == one["문서"] + # 같은 항목 안 아래로(빼기 전 차례) — 첫 줄을 끝 자리 = 둘째로 + inner = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "0.2"]]}])[0] + assert inner["항목"][0]["줄"] == one["항목"][0]["줄"][::-1] + n = len(one["항목"]) + rot = _ts("옮김", [{"문서": gc, "옮김": [["0", str(n)]]}])[0] + assert rot["항목"][-1] == one["항목"][0] and rot["항목"][0] == one["항목"][1] + assert ( + _money(rot["문서"])["계"] == _money(gc)["계"] + ) # 항목만 옮김 = 금액 같음(율 줄은 그 항목 안) + # 율 줄 번지 — 일위대가 복사 = 번지마다 읽는 이름(「복사N」 아님) + me, sk, sc = _combo(world) + g = _into(dc.md.blank("SK-000009", "빈"), _take(me, _rid(sc))) + names = _ts("번지", [{"문서": g["문서"]}])[0] + 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 diff --git a/ui_template/ui_template_toast.ts b/ui_template/ui_template_toast.ts index 4d73ea50a..b432ab998 100644 --- a/ui_template/ui_template_toast.ts +++ b/ui_template/ui_template_toast.ts @@ -90,8 +90,11 @@ export function showToast( ): void { const key = `${kind}::${message}`; const now = Date.now(); - if (lastToast && lastToast.key === key && now < lastToast.until) return; - lastToast = { key, until: now + durationMs }; + // 누름 동작 알림(되돌리기 등)은 조작마다 따로 — 같은 문구여도 버리지 않음(804-5) + if (!onClick) { + if (lastToast && lastToast.key === key && now < lastToast.until) return; + lastToast = { key, until: now + durationMs }; + } const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message }); if (onClick) { toast.style.cursor = "pointer"; From 6f678fb91b57d4641ae03442dace47eda7d3126a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 08:42:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(804-5):=20=EC=A4=84=20=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=ED=91=9C=20=EA=B0=92=20=EC=9D=B4=EB=A6=84=20=C2=B7?= =?UTF-8?q?=20=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