fix(sheet): 마스터 딱지는 식 먼저 · 식 줄은 열 이름 · 머리 글 안 잘리는 최소 폭 · 쪽 나눔 시험 (M02 브레인 확인)

- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력
- 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름
- 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선
- 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
This commit is contained in:
2026-09-25 12:02:13 +09:00
co-authored by Claude Opus 5.5
parent d6302a9695
commit 6cc98d68f1
6 changed files with 272 additions and 24 deletions
+56 -22
View File
@@ -9,11 +9,13 @@
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 {
cellEditable,
isBoundColumn,
isCalcColumn,
orderedRows,
pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
@@ -24,7 +26,6 @@ export const KEY_UNIT = "s:unit";
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
const GUTTER = 64;
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
const PAGE_ROWS = 50;
export interface RenderState {
doc: SheetDoc;
@@ -33,8 +34,18 @@ export interface RenderState {
sel: { r: string; c: string } | null;
}
export const columnWidth = (doc: SheetDoc, col: SheetColumn): number =>
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"];
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
Math.max(
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"],
mins.get(col.id) ?? 0,
);
/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
function headFont(): string {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
}
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
@@ -57,9 +68,13 @@ export function keyEditable(state: RenderState, key: string, col: SheetColumn):
const shown = (text: string | undefined): string =>
!text || text === "0" ? "" : groupDigits(text);
/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */
function kindOf(col: SheetColumn): { cls: string; label: string } | null {
if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") };
if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") };
if (isBoundColumn(col)) {
const label = st("Kind_Bound");
return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label };
}
if (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
return null;
}
@@ -69,6 +84,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const cols = doc.열;
const depth = headDepth(cols, doc.층);
const head = layoutHead(cols, depth);
const names = columnNames(cols);
const mins = headMinWidths(cols, depth, headFont());
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);
@@ -76,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement {
let left = GUTTER;
for (const col of cols) {
lefts.push(left);
left += columnWidth(doc, col);
left += columnWidth(doc, col, mins);
}
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
@@ -133,19 +150,34 @@ export function renderSheet(state: RenderState): HTMLElement {
cols.forEach((col, j) => {
const text =
key === "s:formula"
? (col.식 ?? "")
? formulaLabel(col.식 ?? "", names, col.id)
: key === "s:desc"
? (col.설명 ?? "")
? ""
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
if (key === "s:formula" && col.식) {
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
td.textContent = "";
td.append(el("div", { className: "ui-sheet__clamp", text }));
td.title = `${formulaLabel(col.식, names)}\n${col.식}`;
}
const kind = kindOf(col);
if (key === "s:desc" && kind) {
const chip = el("span", {
className: `ui-sheet__chip ${kind.cls}`,
text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label,
});
td.prepend(chip);
if (key === "s:desc") {
// 두세 줄로 자름 — 전문은 마우스를 올리면(title)
const desc = col.설명 ?? "";
td.title = desc;
td.append(
el("div", {
className: "ui-sheet__clamp",
children: [
...(kind
? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })]
: []),
desc,
],
}),
);
}
tr.append(td);
});
@@ -159,6 +191,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const th = el("th", {
text: cell.label,
attrs: {
title: cell.label,
colspan: String(cell.colspan),
rowspan: String(cell.rowspan),
"data-level": String(cell.level),
@@ -189,7 +222,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] });
cols.forEach((col, j) => {
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
if (totalFormula(total, col)) td.title = totalFormula(total, col)!;
const formula = totalFormula(total, col);
if (formula) td.title = formulaLabel(formula, names);
tr.append(td);
});
return tr;
@@ -199,22 +233,22 @@ export function renderSheet(state: RenderState): HTMLElement {
children: [
el("col", { attrs: { style: `width:${GUTTER}px` } }),
...cols.map((col) =>
el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }),
el("col", {
attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id },
}),
),
],
});
const data = orderedRows(doc);
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length);
const pageCount = Math.max(1, Math.ceil(data.length / size));
const plan = pagePlan(doc, mode);
const pageCount = plan.length;
const pages: HTMLElement[] = [];
for (let p = 0; p < pageCount; p += 1) {
const slice = data.slice(p * size, (p + 1) * size);
for (const [p, page] of plan.entries()) {
const body = el("tbody", {
children: [
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
...slice.map((row, i) => dataRow(row, p * size + i + 1)),
...(p === pageCount - 1 ? totalRows() : []),
...page.rows.map((row, i) => dataRow(row, page.first + i)),
...(page.totals ? totalRows() : []),
],
});
const table = el("table", {