feat(sheet): 엑셀처럼 도는 표 부품 createSheet — master · project 두 모드 (PLAN 10-2)

- 여러 층 머리(null = 세로 합침 · 같은 앞머리 = 가로 합침) · 단위 줄 · 합계 줄 · 틀고정 · 열 너비 끌기
- 셀 편집은 M01 startEdit 그대로 · 화살표 · Tab · Enter · F2 · 바로 치기 · Delete 비움
- 줄 더하기 · 지우기 · master 는 열 더하기 · 지우기 · 손 입력 열 · 식 · 들어갈 것 · 일위대가 줄
- project 는 바인딩 · 계산 열 잠금 · 손 열만 입력 · 전구간 줄 맨 위 · 쪽줄 50 으로 쪽(쪽마다 머리 · 합계 마지막 쪽)
- 고칠 때마다 같은 TS 풀이로 즉시 다시 그림 · onChange 로 문서를 넘김(자동저장 없음)

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 09:41:29 +09:00
co-authored by Claude Opus 5.5
parent 02ba8cbf02
commit 20403340ed
6 changed files with 949 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
/* =============================================================================
* ui_template_sheet.css — 엑셀처럼 도는 표 부품(`ui_template_sheet.ts`)
* 색은 테마 토큰만 — 설계값 = 초록 · 계산 = 노랑 · 손 입력 = 보라 기운
* ========================================================================== */
.ui-sheet {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
outline: none;
}
.ui-sheet__toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.ui-sheet__toolbar .ui-btn.is-on {
background: var(--color-mist-violet);
}
.ui-sheet__hint {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.ui-sheet__scroll {
overflow: auto;
max-height: calc(100vh - 260px);
border: 1px solid var(--color-border);
background: var(--color-canvas);
}
.ui-sheet__pages {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.ui-sheet__table {
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: 0.82rem;
}
.ui-sheet__page {
padding: var(--spacing-4) var(--spacing-8);
color: var(--color-text-secondary);
text-align: left;
}
.ui-sheet__table th,
.ui-sheet__table td {
position: relative;
height: 26px;
padding: 2px 6px;
overflow: hidden;
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
background: var(--color-canvas);
color: var(--color-text-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-sheet__table thead th,
.ui-sheet__table thead td {
background: var(--color-surface);
font-weight: 600;
text-align: center;
}
.ui-sheet__gutter {
position: sticky;
left: 0;
z-index: 2;
background: var(--color-surface) !important;
color: var(--color-text-secondary);
font-weight: 500;
text-align: center;
}
.ui-sheet__table .is-frozen {
position: sticky;
z-index: 1;
box-shadow: 1px 0 0 var(--color-border);
}
.ui-sheet__table td.is-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.ui-sheet__table td.is-bound {
background: color-mix(in srgb, var(--color-success) 7%, var(--color-canvas));
}
.ui-sheet__table td.is-calc {
background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas));
}
.ui-sheet__table td.is-hand {
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
}
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
color: var(--color-text-secondary);
}
.ui-sheet__table td.is-override {
font-style: italic;
}
.ui-sheet__table td.is-error {
color: var(--color-danger);
}
.ui-sheet__table .is-editable {
cursor: cell;
}
.ui-sheet__table td.is-selected {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
.ui-sheet__table tr.is-fixed td {
border-bottom: 2px solid var(--color-border);
}
.ui-sheet__meta td {
color: var(--color-text-secondary);
font-size: 0.76rem;
white-space: normal;
}
.ui-sheet__total td,
.ui-sheet__total th {
border-top: 2px solid var(--color-border);
font-weight: 600;
}
.ui-sheet__chip {
display: inline-block;
margin-right: 4px;
padding: 0 4px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.ui-sheet__chip.is-bound {
background: color-mix(in srgb, var(--color-success) 18%, var(--color-canvas));
color: var(--color-success);
}
.ui-sheet__chip.is-calc {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
color: var(--color-warning);
}
.ui-sheet__chip.is-hand {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.ui-sheet__resize {
position: absolute;
top: 0;
right: -3px;
z-index: 3;
width: 6px;
height: 100%;
cursor: col-resize;
}
.ui-sheet__table td input,
.ui-sheet__table th input {
width: 100%;
min-width: 0;
padding: 0 2px;
border: 0;
font: inherit;
text-align: inherit;
}
+323
View File
@@ -0,0 +1,323 @@
/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 칸 고치기는 M01 `startEdit`(Enter · 칸 밖 = 확정 · Esc = 취소) 를 그대로 씀.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
import {
addColumn,
addRow,
canDeleteRow,
deleteColumn,
deleteRow,
type SheetMode,
setCell,
setColumnWidth,
} from "./ui_template_sheet_ops";
import { recalcSheet } from "./ui_template_sheet_recalc";
import {
columnWidth,
KEY_UNIT,
keyEditable,
navRows,
type RenderState,
renderSheet,
} from "./ui_template_sheet_render";
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
import "./ui_template_sheet.css";
export type { SheetMode } from "./ui_template_sheet_ops";
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
export interface SheetOptions {
mode: SheetMode;
onChange?: (doc: SheetDoc) => void;
}
export interface SheetHandle {
getDoc(): SheetDoc;
setDoc(doc: SheetDoc): void;
recalc(): SheetResult;
destroy(): void;
}
const MIN_WIDTH = 32;
export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptions): SheetHandle {
const state: RenderState = {
doc: structuredClone(input),
mode: opts.mode,
result: { 계산: {}, 합계: {}, 오류: [] },
sel: null,
};
const scroll = el("div", { className: "ui-sheet__scroll" });
const toolbar = el("div", { className: "ui-sheet__toolbar" });
const root = el("div", {
className: `ui-sheet ui-sheet--${opts.mode}`,
attrs: { tabindex: "0" },
children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll],
});
host.replaceChildren(root);
const colOf = (id: string): SheetColumn | undefined => state.doc.열.find((c) => c.id === id);
const rowIdOf = (key: string | undefined): string | null =>
key?.startsWith("d:") ? key.slice(2) : null;
// ── 단추 ──────────────────────────────────────────────────────────
const button = (label: string, run: () => void): HTMLButtonElement =>
createButton({ label, variant: "ghost", onClick: run });
const rowAdd = button(st("Row_Add"), () => {
const id = addRow(state.doc, state.mode, rowIdOf(state.sel?.r));
state.sel = { r: `d:${id}`, c: state.sel?.c ?? state.doc.열[0]?.id ?? "" };
changed();
});
const rowDelete = button(st("Row_Delete"), () => {
const id = rowIdOf(state.sel?.r);
if (!id) return;
const keys = navRows(state.doc, state.mode);
deleteRow(state.doc, id);
const next = keys[keys.indexOf(`d:${id}`) + 1] ?? keys[keys.indexOf(`d:${id}`) - 1];
state.sel = next && state.sel ? { r: next, c: state.sel.c } : null;
changed();
});
const colAdd = button(st("Col_Add"), () => {
const id = addColumn(state.doc, state.sel?.c ?? null, st("Col_New"));
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
changed();
});
const colDelete = button(st("Col_Delete"), () => {
const id = state.sel?.c;
if (!id || state.doc.열.length <= 1) return;
const at = state.doc.열.findIndex((c) => c.id === id);
deleteColumn(state.doc, id);
const next = state.doc.열[Math.min(at, state.doc.열.length - 1)];
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
changed();
});
const colHand = button(st("Col_Hand"), () => {
const col = state.sel && colOf(state.sel.c);
if (!col) return;
if (col.손) delete col.손;
else col.손 = true;
changed();
});
toolbar.append(rowAdd, rowDelete);
if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand);
const syncToolbar = (): void => {
const rowId = rowIdOf(state.sel?.r);
const row = state.doc.줄.find((r) => r.id === rowId);
rowDelete.disabled = !row || !canDeleteRow(state.mode, row);
const col = state.sel ? colOf(state.sel.c) : undefined;
colDelete.disabled = !col || state.doc.열.length <= 1;
colHand.disabled = !col;
colHand.classList.toggle("is-on", !!col?.손);
};
// ── 그리기 · 고름 ─────────────────────────────────────────────────
const render = (): void => {
state.result = recalcSheet(state.doc);
const { scrollLeft, scrollTop } = scroll;
scroll.replaceChildren(renderSheet(state));
scroll.scrollLeft = scrollLeft;
scroll.scrollTop = scrollTop;
syncToolbar();
};
const changed = (): void => {
render();
opts.onChange?.(structuredClone(state.doc));
};
const cellsAt = (r: string, c: string): HTMLElement[] => [
...scroll.querySelectorAll<HTMLElement>(
`td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`,
),
];
const select = (r: string, c: string, reveal = false): void => {
for (const cell of scroll.querySelectorAll(".is-selected"))
cell.classList.remove("is-selected");
state.sel = { r, c };
const cells = cellsAt(r, c);
for (const cell of cells) cell.classList.add("is-selected");
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
syncToolbar();
};
const move = (dr: number, dc: number): void => {
const keys = navRows(state.doc, state.mode);
const cols = state.doc.열;
if (!state.sel) {
if (keys.length && cols.length) select(keys[0], cols[0].id, true);
return;
}
const r = Math.max(0, Math.min(keys.length - 1, keys.indexOf(state.sel.r) + dr));
const at = cols.findIndex((c) => c.id === state.sel!.c);
const c = Math.max(0, Math.min(cols.length - 1, at + dc));
select(keys[r], cols[c].id, true);
};
// ── 칸 고치기 ─────────────────────────────────────────────────────
const rawText = (key: string, col: SheetColumn): string => {
if (key === KEY_UNIT) return col.단위 ?? "";
if (key === "s:formula") return col.식 ?? "";
if (key === "s:desc") return col.설명 ?? "";
if (key === "s:price") return col.일위대가 ?? "";
const raw = state.doc.줄.find((r) => `d:${r.id}` === key)?.값[col.id];
return raw === null || raw === undefined ? "" : String(raw);
};
const write = (key: string, col: SheetColumn, text: string): void => {
const value = text.trim();
if (key === KEY_UNIT) col.단위 = value || null;
else if (key === "s:formula") {
if (value) col.식 = value;
else delete col.식;
} else if (key === "s:desc") col.설명 = value;
else if (key === "s:price") col.일위대가 = value || null;
else setCell(state.doc, key.slice(2), col, text);
changed();
};
const edit = (initial?: string): void => {
const sel = state.sel;
const col = sel && colOf(sel.c);
if (!sel || !col || !keyEditable(state, sel.r, col)) return;
const td = cellsAt(sel.r, sel.c)[0];
if (!td) return;
startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value));
const box = td.querySelector("input");
if (box && initial !== undefined) box.value = initial;
};
const editHead = (th: HTMLElement): void => {
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
const level = Number(th.dataset.level);
if (start < 0 || Number.isNaN(level)) return;
const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan);
startEdit(th, th.textContent ?? "", (value) => {
for (const col of span) {
while (col.머리.length <= level) col.머리.push(null);
col.머리[level] = value.trim();
}
changed();
});
};
// ── 사건 ──────────────────────────────────────────────────────────
const onClick = (event: MouseEvent): void => {
const target = event.target as HTMLElement;
if (target.closest("input")) return;
const td = target.closest<HTMLElement>("td[data-r]");
if (td) select(td.dataset.r!, td.dataset.c!);
const th = target.closest<HTMLElement>("th[data-col]");
if (th) select(state.sel?.r ?? KEY_UNIT, th.dataset.col!);
root.focus({ preventScroll: true });
};
const onDblClick = (event: MouseEvent): void => {
const target = event.target as HTMLElement;
if (target.closest("input")) return;
const th = target.closest<HTMLElement>("th[data-col]");
if (th && state.mode === "master") editHead(th);
else if (target.closest("td[data-r]")) edit();
};
const onKey = (event: KeyboardEvent): void => {
const typing = (event.target as HTMLElement).tagName === "INPUT";
if (typing) {
if (event.key === "Enter") {
move(1, 0);
root.focus({ preventScroll: true });
} else if (event.key === "Tab") {
event.preventDefault();
(event.target as HTMLInputElement).blur();
move(0, event.shiftKey ? -1 : 1);
root.focus({ preventScroll: true });
} else if (event.key === "Escape") {
render();
root.focus({ preventScroll: true });
}
return;
}
const arrows: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
if (event.key in arrows) {
event.preventDefault();
move(...arrows[event.key]);
} else if (event.key === "Tab") {
event.preventDefault();
move(0, event.shiftKey ? -1 : 1);
} else if (event.key === "Enter" || event.key === "F2") {
event.preventDefault();
edit();
} else if (event.key === "Delete" || event.key === "Backspace") {
const col = state.sel && colOf(state.sel.c);
if (state.sel && col && keyEditable(state, state.sel.r, col)) {
event.preventDefault();
write(state.sel.r, col, "");
}
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
edit(event.key);
}
};
// 열 너비 끌기 — 단위 줄 칸 오른쪽 손잡이
let drag: { id: string; x: number; width: number; total: number } | null = null;
const onPointerDown = (event: PointerEvent): void => {
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-resize]");
const col = handle && colOf(handle.dataset.resize!);
if (!handle || !col) return;
event.preventDefault();
handle.setPointerCapture(event.pointerId);
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total };
};
const onPointerMove = (event: PointerEvent): void => {
if (!drag) return;
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
for (const c of scroll.querySelectorAll<HTMLElement>(`col[data-col="${CSS.escape(drag.id)}"]`))
c.style.width = `${width}px`;
for (const table of scroll.querySelectorAll<HTMLElement>("table"))
table.style.width = `${drag.total - drag.width + width}px`;
};
const onPointerUp = (event: PointerEvent): void => {
if (!drag) return;
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
const id = drag.id;
drag = null;
setColumnWidth(state.doc, id, width);
changed();
};
root.addEventListener("click", onClick);
root.addEventListener("dblclick", onDblClick);
root.addEventListener("keydown", onKey);
root.addEventListener("pointerdown", onPointerDown);
root.addEventListener("pointermove", onPointerMove);
root.addEventListener("pointerup", onPointerUp);
render();
return {
getDoc: () => structuredClone(state.doc),
setDoc: (doc) => {
state.doc = structuredClone(doc);
state.sel = null;
render();
},
recalc: () => {
render();
return structuredClone(state.result);
},
destroy: () => {
root.remove();
drag = null;
},
};
}
@@ -0,0 +1,61 @@
/* =============================================================================
* ui_template_sheet_header.ts
* 여러 층 머리 짜기 — 열마다 `머리`(층 수만큼 글) → 층마다 칸(colspan · rowspan).
*
* `null` = 위 칸과 합침(세로) · 모자란 층도 `null` 로 채움(위 칸이 늘어남).
* 옆 열과 그 층까지 앞머리가 모두 같고 아래 합침 모양도 같으면 가로로 합침.
* ========================================================================== */
import type { SheetColumn } from "./ui_template_sheet_types";
export interface HeadCell {
level: number;
col: number;
colspan: number;
rowspan: number;
label: string;
}
export function headDepth(cols: SheetColumn[], layers?: string[]): number {
return Math.max(1, layers?.length ?? 0, ...cols.map((c) => c.머리.length));
}
/** 층마다 칸 목록 — 앞 층부터. */
export function layoutHead(cols: SheetColumn[], depth: number): HeadCell[][] {
const paths = cols.map((c) =>
Array.from({ length: depth }, (_, i) => (i === 0 ? (c.머리[0] ?? "") : (c.머리[i] ?? null))),
);
const span = (j: number, i: number): number => {
let k = i + 1;
while (k < depth && paths[j][k] === null) k += 1;
return k - i;
};
const samePrefix = (a: number, b: number, i: number): boolean =>
paths[a].slice(0, i + 1).every((label, lv) => label === paths[b][lv]);
const levels: HeadCell[][] = [];
for (let i = 0; i < depth; i += 1) {
const cells: HeadCell[] = [];
let j = 0;
while (j < cols.length) {
if (paths[j][i] === null) {
j += 1;
continue;
}
const rowspan = span(j, i);
let colspan = 1;
while (
j + colspan < cols.length &&
paths[j + colspan][i] !== null &&
samePrefix(j, j + colspan, i) &&
span(j + colspan, i) === rowspan
) {
colspan += 1;
}
cells.push({ level: i, col: j, colspan, rowspan, label: paths[j][i] as string });
j += colspan;
}
levels.push(cells);
}
return levels;
}
+107
View File
@@ -0,0 +1,107 @@
/* =============================================================================
* ui_template_sheet_ops.ts
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
* ========================================================================== */
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
export type SheetMode = "master" | "project";
export const ROW_FIXED_ALL = "전구간";
/** 겹치지 않는 새 id — `c1` · `c2` … */
function freshId(prefix: string, used: Iterable<string>): string {
const taken = new Set(used);
let n = 1;
while (taken.has(`${prefix}${n}`)) n += 1;
return `${prefix}${n}`;
}
/** 보이는 줄 차례 — 「전구간」 줄이 맨 위 · 나머지는 문서 차례. */
export function orderedRows(doc: SheetDoc): SheetRow[] {
const fixed = doc.줄.filter((r) => r.고정 === ROW_FIXED_ALL);
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
}
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
/** 본문 칸을 고칠 수 있나 — master 는 계산 열 밖 전부 · project 는 손 열만(+ 사용자가 더한 줄의 글 열). */
export function cellEditable(mode: SheetMode, col: SheetColumn, row: SheetRow): boolean {
if (row.식?.[col.id] || isCalcColumn(col)) return false;
if (mode === "master") return true;
if (isBoundColumn(col)) return false;
return !!col.손 || (!!row.손 && col.꼴 === "글");
}
export function canDeleteRow(mode: SheetMode, row: SheetRow): boolean {
if (mode === "master") return true;
return !!row.손 && row.고정 !== ROW_FIXED_ALL;
}
/** `after` 줄 뒤에 빈 줄(없으면 맨 끝) — 새 줄 id. project 에서 더한 줄은 `손`. */
export function addRow(doc: SheetDoc, mode: SheetMode, after: string | null): string {
const id = freshId(
"r",
doc.줄.map((r) => r.id),
);
const row: SheetRow = { id, 값: {}, ...(mode === "project" ? { 손: true } : {}) };
const at = doc.줄.findIndex((r) => r.id === after);
doc.줄.splice(at < 0 ? doc.줄.length : at + 1, 0, row);
return id;
}
export function deleteRow(doc: SheetDoc, id: string): void {
doc.줄 = doc.줄.filter((r) => r.id !== id);
}
/** `after` 열 오른쪽에 같은 묶음의 새 열 — 윗머리는 그대로 · 맨 아래 글만 「새 열」. */
export function addColumn(doc: SheetDoc, after: string | null, label: string): string {
const id = freshId(
"c",
doc.열.map((c) => c.id),
);
const at = doc.열.findIndex((c) => c.id === after);
const base = at < 0 ? null : doc.열[at];
const head = base ? [...base.머리] : [label];
if (base) {
let last = head.length - 1;
while (last > 0 && head[last] === null) last -= 1;
head[last] = label;
}
const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" };
doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col);
return id;
}
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
export function deleteColumn(doc: SheetDoc, id: string): void {
doc.열 = doc.열.filter((c) => c.id !== id);
for (const row of doc.줄) {
delete row.값[id];
if (row.식) delete row.식[id];
}
if (doc.보기?.열너비) delete doc.보기.열너비[id];
}
/** 칸에 친 글 → 저장 값. 수 열은 수 꼴이면 수 · 빈 글은 칸을 비움. */
export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: string): void {
const row = doc.줄.find((r) => r.id === rowId);
if (!row) return;
const trimmed = text.trim();
if (trimmed === "") {
delete row.값[col.id];
return;
}
const plain = trimmed.replace(/,/g, "");
const value: SheetCell =
col.꼴 !== "글" && /^-?(\d+(\.\d*)?|\.\d+)$/.test(plain) ? Number(plain) : trimmed;
// ⚠ 긴 소수는 Number 로 바꾸면 끝자리가 틀어짐 — 보이는 글 그대로 둠(풀이가 십진으로 읽음)
row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value;
}
export function setColumnWidth(doc: SheetDoc, id: string, width: number): void {
doc.보기 ??= {};
(doc.보기.열너비 ??= {})[id] = Math.round(width);
}
@@ -0,0 +1,236 @@
/* =============================================================================
* ui_template_sheet_render.ts
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
*
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`.
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { headDepth, layoutHead } from "./ui_template_sheet_header";
import {
cellEditable,
isBoundColumn,
isCalcColumn,
orderedRows,
type SheetMode,
} from "./ui_template_sheet_ops";
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
export const KEY_UNIT = "s:unit";
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
const GUTTER = 48;
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
const PAGE_ROWS = 50;
export interface RenderState {
doc: SheetDoc;
mode: SheetMode;
result: SheetResult;
sel: { r: string; c: string } | null;
}
export const columnWidth = (doc: SheetDoc, col: SheetColumn): number =>
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"];
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
return [
...special,
...orderedRows(doc).map((r) => `d:${r.id}`),
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
];
}
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
if (key.startsWith("s:")) return state.mode === "master";
if (key.startsWith("t:")) return false;
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
return !!row && cellEditable(state.mode, col, row);
}
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 (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
return null;
}
export function renderSheet(state: RenderState): HTMLElement {
const { doc, mode } = state;
const cols = doc.열;
const depth = headDepth(cols, doc.층);
const head = layoutHead(cols, depth);
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);
const lefts: number[] = [];
let left = GUTTER;
for (const col of cols) {
lefts.push(left);
left += columnWidth(doc, col);
}
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
if (from + span > frozen) return;
cell.classList.add("is-frozen");
cell.style.left = `${lefts[from]}px`;
};
const gutter = (tag: "th" | "td", text: string, cls = ""): HTMLElement =>
el(tag, { className: `ui-sheet__gutter ${cls}`.trim(), text });
const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => {
const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } });
const kind = kindOf(col);
if (kind) td.classList.add(kind.cls);
if (col.꼴 !== "글") td.classList.add("is-num");
td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked");
const why = errors.get(`${key}|${col.id}`);
if (why) {
td.classList.add("is-error");
td.textContent = st("Error");
td.title = why;
} else if (text) td.title = text;
if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected");
freeze(td, j);
return td;
};
const dataRow = (row: SheetRow, n: number): HTMLElement => {
const key = `d:${row.id}`;
const tr = el("tr", { children: [gutter("th", String(n))] });
if (row.고정) tr.classList.add("is-fixed");
cols.forEach((col, j) => {
const formula = row.식?.[col.id] ?? col.식;
const raw = row.값[col.id];
const text = formula
? shown(state.result.계산[row.id]?.[col.id])
: raw === null || raw === undefined
? ""
: col.꼴 !== "글" && typeof raw === "number"
? groupDigits(String(raw))
: String(raw);
const td = bodyCell(key, col, j, text);
if (row.식?.[col.id]) td.classList.add("is-override");
tr.append(td);
});
return tr;
};
const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => {
const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[
key
] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price";
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
cols.forEach((col, j) => {
const text =
key === "s:formula"
? (col.식 ?? "")
: key === "s:desc"
? (col.설명 ?? "")
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
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);
}
tr.append(td);
});
return tr;
};
const thead = (): HTMLElement => {
const rows = head.map((cells, level) => {
const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] });
for (const cell of cells) {
const th = el("th", {
text: cell.label,
attrs: {
colspan: String(cell.colspan),
rowspan: String(cell.rowspan),
"data-level": String(cell.level),
"data-col": cols[cell.col].id,
},
});
if (mode === "master") th.classList.add("is-editable");
freeze(th, cell.col, cell.colspan);
tr.append(th);
}
return tr;
});
const unit = el("tr", {
className: "ui-sheet__unit",
children: [gutter("th", st("Row_Unit"))],
});
cols.forEach((col, j) => {
const th = bodyCell(KEY_UNIT, col, j, col.단위 ?? "");
th.classList.remove("is-num");
th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } }));
unit.append(th);
});
return el("thead", { children: [...rows, unit] });
};
const totalRows = (): HTMLElement[] =>
(doc.합계줄 ?? []).map((total) => {
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)!;
tr.append(td);
});
return tr;
});
const colgroup = el("colgroup", {
children: [
el("col", { attrs: { style: `width:${GUTTER}px` } }),
...cols.map((col) =>
el("col", { attrs: { style: `width:${columnWidth(doc, col)}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 pages: HTMLElement[] = [];
for (let p = 0; p < pageCount; p += 1) {
const slice = data.slice(p * size, (p + 1) * size);
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() : []),
],
});
const table = el("table", {
className: "ui-sheet__table",
attrs: { style: `width:${left}px` },
children: [colgroup.cloneNode(true) as HTMLElement, thead(), body],
});
if (pageCount > 1) {
table.prepend(
el("caption", {
text: `${p + 1} / ${pageCount} ${st("Page")}`,
className: "ui-sheet__page",
}),
);
}
pages.push(table);
}
return el("div", { className: "ui-sheet__pages", children: pages });
}
@@ -0,0 +1,33 @@
/* =============================================================================
* ui_template_sheet_text.ts
* 표 부품 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Row_Add: ["줄 더하기", "Add row"],
Row_Delete: ["줄 지우기", "Delete row"],
Col_Add: ["열 더하기", "Add column"],
Col_Delete: ["열 지우기", "Delete column"],
Col_Hand: ["손 입력 열", "Manual column"],
Col_New: ["새 열", "New column"],
Row_Unit: ["단위", "Unit"],
Row_Formula: ["식", "Formula"],
Row_Desc: ["들어갈 것", "Source"],
Row_Unit_Price: ["일위대가", "Unit price"],
Kind_Bound: ["설계값", "Design"],
Kind_Calc: ["계산", "Calc"],
Kind_Hand: ["손 입력", "Manual"],
Kind_Spread: ["값마다 열", "Per value"],
Page: ["쪽", "page"],
Error: ["#오류", "#ERR"],
Hint: [
"칸을 누르고 바로 치거나 Enter · F2 로 고침 · 화살표 · Tab 으로 옮김 · Delete 로 비움",
"Type or press Enter / F2 to edit · arrows / Tab to move · Delete to clear",
],
} as const;
export function st(key: keyof typeof TEXT): string {
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
}