/* ============================================================================= * ui_template_sheet_ops.ts * 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림). * 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름. * ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`). * ========================================================================== */ import type { SheetCell, SheetColumn, SheetDoc, SheetRow, SheetSelection, } from "./ui_template_sheet_types"; export type SheetMode = "master" | "project"; export const ROW_FIXED_ALL = "전구간"; const PAGE_ROWS = 50; export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; export const KEY_MATERIAL = "s:material"; /** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 · 재료산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_MATERIAL] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; /** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */ export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA, KEY_MATERIAL]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; /** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 · 재료산출 칸 — 빈칸 · 잠김. */ export const isBlankPick = (key: string, col: SheetColumn): boolean => PICK_ROWS.includes(key) && 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 selectionOf(doc: SheetDoc, row: string, colId: string): SheetSelection { const col = doc.열.findIndex((c) => c.id === colId); return { row, col, colId: col >= 0 ? colId : null }; } /** 겹치지 않는 새 id — `c1` · `c2` … */ function freshId(prefix: string, used: Iterable): 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 interface SheetPage { rows: SheetRow[]; /** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */ first: number; /** 합계 줄을 붙이나 — 마지막 쪽만. */ totals: boolean; } /** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */ export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] { const rows = orderedRows(doc); const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length); const count = Math.max(1, Math.ceil(rows.length / size)); return Array.from({ length: count }, (_, p) => ({ rows: rows.slice(p * size, (p + 1) * size), first: p * size + 1, totals: p === count - 1, })); } 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 (mode === "master") return true; if (isBoundColumn(col) || isCalcColumn(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); } /** `level` 층 새 칸 열 — `after` 열과 그 층 위 머리(`머리[0..level-1]`)가 같은 이웃 끝에 둠 · * `[…위 머리, label, "" …]`(아래 층은 빈칸) · 같은 무리에 같은 글이 있으면 뒤에 수를 붙임 * (같은 머리는 가로로 합쳐져 새 열이 안 보임) · 새 열 id(`after` 가 없으면 null). */ export function addLevel( doc: SheetDoc, after: string, level: number, depth: number, label: string, ): string | null { let at = doc.열.findIndex((c) => c.id === after); if (at < 0) return null; const base = doc.열[at]; const top = Array.from({ length: level }, (_, i) => base.머리[i] ?? null); const mates = (col: SheetColumn): boolean => top.every((h, i) => (col.머리[i] ?? null) === h); while (at + 1 < doc.열.length && mates(doc.열[at + 1])) at += 1; const used = new Set(doc.열.filter(mates).map((c) => c.머리[level])); let text = label; for (let n = 2; used.has(text); n += 1) text = `${label} ${n}`; const head: (string | null)[] = [...top, text]; while (head.length < depth) head.push(""); const id = freshId( "c", doc.열.map((c) => c.id), ); // 원본(`after`) 열을 따라감 — 일위대가 · 자재조합 같은 키 · 구조물 파일은 저장 때 서버가 복사 doc.열.splice(at + 1, 0, { id, 머리: head, 단위: base.단위 ?? null, 꼴: "수", ...(base.꼴 !== "글" ? { 본뜬열: base.id, 일위대가: base.일위대가 ?? null, 자재조합: base.자재조합 ?? null } : {}), }); return id; } /** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */ export function setLayerName(doc: SheetDoc, level: number, text: string): void { const layers = (doc.층 ??= []); while (layers.length <= level) layers.push(""); layers[level] = text.trim(); } /** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */ 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 (row.식) { delete row.식[col.id]; if (!Object.keys(row.식).length) delete row.식; } if (trimmed.startsWith("=") && trimmed.slice(1).trim() !== (col.식 ?? "")) { delete row.값[col.id]; (row.식 ??= {})[col.id] = trimmed.slice(1).trim(); return; } if (trimmed === "" || trimmed.startsWith("=")) { 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; } /** 머리 칸 고치기 — 글에 `/` 가 있으면 `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); }