fix(sheet): 머리 칸 / 로 층 나눔 · 줄 머리 칸 고정 · 줄 머리 칸 폭 자동 (M02 재검증)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
This commit is contained in:
2026-09-25 12:45:57 +09:00
co-authored by Claude Sonnet 5
parent 4679261634
commit acca7d1fb6
7 changed files with 98 additions and 10 deletions
+33
View File
@@ -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],
]
+13 -2
View File
@@ -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;
+11 -4
View File
@@ -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());
};
// ── 사건 ──────────────────────────────────────────────────────────
@@ -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;
@@ -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);
+20 -3
View File
@@ -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);
@@ -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 로 비움",