diff --git a/resources/tester/test_sheet_pages.py b/resources/tester/test_sheet_pages.py index afeedecb..f2e74b7e 100644 --- a/resources/tester/test_sheet_pages.py +++ b/resources/tester/test_sheet_pages.py @@ -79,3 +79,36 @@ def test_page_rows_from_doc_and_master_single_page(): def test_empty_doc_still_one_page_with_totals(): plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"] assert plan == [{"ids": [], "first": 1, "totals": True}] + + +HEAD_SCRIPT = """ +const { setHeadLabel } = await import(process.argv[1]); +const out = JSON.parse(process.argv[2]).map(([head, level, text, depth]) => { + setHeadLabel(head, level, text, depth); + return head; +}); +console.log(JSON.stringify(out)); +""" + + +def test_head_label_slash_splits_layers(): + cases = [ + [["새 열", None, None], 0, "관공/Φ800/관매설", 3], + [["새 열", None, None], 0, "관공/Φ800", 3], # 적게 → 남은 층 합침 + [["새 열", None, None], 0, "a/b/c/d", 3], # 많으면 끝 층에 이어 붙임 + [["종류", "공법", "규격"], 1, "이름", 3], # / 없음 → 그 층만 + [["새 열"], 0, "가/나", 3], # 모자란 층은 채움 + ] + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", + HEAD_SCRIPT, OPS.as_uri(), json.dumps(cases, ensure_ascii=False)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["관공", "Φ800", "관매설"], + ["관공", "Φ800", None], + ["a", "b", "c/d"], + ["종류", "이름", "규격"], + ["가", "나", None], + ] diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 35ee2794..e9a1f569 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -74,10 +74,10 @@ text-align: center; } -.ui-sheet__gutter { +.ui-sheet__table .ui-sheet__gutter { position: sticky; left: 0; - z-index: 2; + z-index: 4; background: var(--color-surface) !important; color: var(--color-text-secondary); font-weight: 500; @@ -176,6 +176,17 @@ color: var(--color-accent); } +.ui-sheet__headhint { + position: fixed; + z-index: 10; + padding: 2px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: 0.76rem; +} + .ui-sheet__resize { position: absolute; top: 0; diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 4b013afb..2249e240 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -20,7 +20,9 @@ import { type SheetMode, setCell, setColumnWidth, + setHeadLabel, } from "./ui_template_sheet_ops"; +import { headDepth } from "./ui_template_sheet_header"; import { recalcSheet } from "./ui_template_sheet_recalc"; import { KEY_UNIT, @@ -123,6 +125,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio // ── 그리기 · 고름 ───────────────────────────────────────────────── const render = (): void => { + root.querySelector(".ui-sheet__headhint")?.remove(); state.result = recalcSheet(state.doc); const { scrollLeft, scrollTop } = scroll; scroll.replaceChildren(renderSheet(state)); @@ -200,13 +203,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const level = Number(th.dataset.level); if (start < 0 || Number.isNaN(level)) return; const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan); + const depth = headDepth(state.doc.열, state.doc.층); + const tip = el("div", { className: "ui-sheet__headhint", text: st("Head_Hint") }); + const at = th.getBoundingClientRect(); + tip.style.left = `${at.left}px`; + tip.style.top = `${at.bottom + 2}px`; + root.append(tip); startEdit(th, th.textContent ?? "", (value) => { - for (const col of span) { - while (col.머리.length <= level) col.머리.push(null); - col.머리[level] = value.trim(); - } + for (const col of span) setHeadLabel(col.머리, level, value, depth); changed(); }); + th.querySelector("input")?.addEventListener("blur", () => tip.remove()); }; // ── 사건 ────────────────────────────────────────────────────────── diff --git a/ui_template/sheet/ui_template_sheet_labels.ts b/ui_template/sheet/ui_template_sheet_labels.ts index e86c37dc..f90b885d 100644 --- a/ui_template/sheet/ui_template_sheet_labels.ts +++ b/ui_template/sheet/ui_template_sheet_labels.ts @@ -76,7 +76,7 @@ export function formulaLabel( let ruler: CanvasRenderingContext2D | null = null; -function textWidth(text: string, font: string): number { +export function textWidth(text: string, font: string): number { ruler ??= document.createElement("canvas").getContext("2d"); if (!ruler) return text.length * 13; ruler.font = font; diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index a4892174..e3d1be71 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -131,6 +131,22 @@ export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: st row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value; } +/** 머리 칸 고치기 — 글에 `/` 가 있으면 `level` 층부터 아래로 나눠 넣음(층보다 많으면 끝 층에 이어 붙임) · + * 층보다 적게 쓰면 남은 층은 `null`(위 칸과 합침). `/` 가 없으면 그 층 글만 바꿈. */ +export function setHeadLabel(head: (string | null)[], level: number, text: string, depth: number) { + while (head.length <= level) head.push(null); + const parts = text.split("/").map((part) => part.trim()); + if (parts.length < 2) { + head[level] = text.trim(); + return; + } + const room = Math.max(1, depth - level); + const fit = + parts.length > room ? [...parts.slice(0, room - 1), parts.slice(room - 1).join("/")] : parts; + while (head.length < depth) head.push(null); + for (let k = level; k < depth; k += 1) head[k] = fit[k - level] ?? null; +} + export function setColumnWidth(doc: SheetDoc, id: string, width: number): void { doc.보기 ??= {}; (doc.보기.열너비 ??= {})[id] = Math.round(width); diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 2d9ab72c..7bef596f 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -9,7 +9,7 @@ import { el } from "@ui/ui_template_elements"; import { headDepth, layoutHead } from "./ui_template_sheet_header"; -import { columnNames, formulaLabel, headMinWidths } from "./ui_template_sheet_labels"; +import { columnNames, formulaLabel, headMinWidths, textWidth } from "./ui_template_sheet_labels"; import { cellEditable, isBoundColumn, @@ -24,7 +24,8 @@ import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template export const KEY_UNIT = "s:unit"; const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const; -const GUTTER = 64; +const GUTTER_MIN = 64; +const GUTTER_MAX = 140; const DEFAULT_WIDTH = { 수: 72, 글: 96 }; export interface RenderState { @@ -85,7 +86,23 @@ export function renderSheet(state: RenderState): HTMLElement { const depth = headDepth(cols, doc.층); const head = layoutHead(cols, depth); const names = columnNames(cols); - const mins = headMinWidths(cols, depth, headFont()); + const font = headFont(); + const mins = headMinWidths(cols, depth, font); + // 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 합계 이름이 안 잘리게 + const GUTTER = Math.min( + GUTTER_MAX, + Math.max( + GUTTER_MIN, + ...[ + ...(doc.층 ?? []), + st("Row_Unit"), + st("Row_Formula"), + st("Row_Desc"), + st("Row_Unit_Price"), + ...(doc.합계줄 ?? []).map((t) => t.이름), + ].map((label) => Math.ceil(textWidth(label, font)) + 16), + ), + ); const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭])); for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭); const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length); diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 37ac1a63..6dd8d091 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -21,6 +21,10 @@ const TEXT = { Kind_Hand: ["손 입력", "Manual"], Kind_Spread: ["값마다 열", "Per value"], Page: ["쪽", "page"], + Head_Hint: [ + "/ 로 이으면 층으로 나뉨 (예 관공/Φ800/관매설) · 적게 쓰면 남은 층은 위 칸과 합침", + "Use / to split into layers (e.g. A/B/C) · fewer parts merge the rest with the cell above", + ], Error: ["#오류", "#ERR"], Hint: [ "칸을 누르고 바로 치거나 Enter · F2 로 고침 · = 로 시작하면 그 칸만 식 · 화살표 · Tab 으로 옮김 · Delete 로 비움",