- 112-1 면적 표 변환을 공용 파일(spreadsheet_area_table.ts)로 빼 서버 Node 와 브라우저가 한 벌로 씀 · 도면 패널이 칸 고침마다(값 바뀐 표만 · 펼친 동안만) CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 보냄 - 112-2 보낸 범위 이름은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(놓기만 하고 저장 안 하면 안 생김) · [보내기 취소]는 화면에서만 빼고 면적 [저장] 때 서버가 연결표 줄 · 이름을 뺌 · /name · /unlink 길 삭제 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
148 lines
6.6 KiB
TypeScript
148 lines
6.6 KiB
TypeScript
/* =============================================================================
|
||
* spreadsheet_area_table.ts
|
||
* 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 한 벌의 코드를 두 곳이 씀(CLAUDE.md 5장 ②):
|
||
* 서버 Node(`common_util/common_util_spreadsheet_node.ts` · [저장] 때 정본) ·
|
||
* 브라우저(M02 면적 상세 오른쪽 도면 패널 · 저장 전 실시간 표 값 · 112-1).
|
||
* 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 ·
|
||
* style.연결 빈 글(화면이 붙임) · 저장 안 함.
|
||
* ========================================================================== */
|
||
|
||
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
|
||
import { cellStyleAt } from "./spreadsheet_cellstyle";
|
||
import { createCalcEngine } from "./spreadsheet_graph";
|
||
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
|
||
import { resolveName } from "./spreadsheet_names";
|
||
import { formatValue } from "./spreadsheet_numfmt";
|
||
import type { CellRange, HAlign, Workbook } from "./spreadsheet_types";
|
||
|
||
/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */
|
||
export const NAME_GONE = "이름없음";
|
||
|
||
/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면
|
||
* 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */
|
||
export interface TableAsk {
|
||
book: Workbook;
|
||
시트: string;
|
||
범위: string;
|
||
이름?: string;
|
||
글자크기?: number;
|
||
한도?: number;
|
||
}
|
||
|
||
/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */
|
||
export interface TableCell {
|
||
text: string;
|
||
colSpan?: number;
|
||
rowSpan?: number;
|
||
align?: "left" | "center" | "right";
|
||
bold?: boolean;
|
||
key: string;
|
||
}
|
||
|
||
const FONT = 2.5;
|
||
const LIMIT = 2000;
|
||
/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */
|
||
const COL_K = 0.7;
|
||
const ROW_K = 1.8;
|
||
const ALIGN: Partial<Record<HAlign, TableCell["align"]>> = {
|
||
left: "left",
|
||
center: "center",
|
||
right: "right",
|
||
distributed: "center",
|
||
centerContinuous: "center",
|
||
};
|
||
|
||
export function areaTable(ask: TableAsk) {
|
||
const book = ask.book;
|
||
let sheetId = ask.시트;
|
||
let range: CellRange;
|
||
if (ask.이름) {
|
||
const found = resolveName(book, ask.시트, ask.이름);
|
||
if (!found) throw new Error(NAME_GONE); // 이름을 지웠거나 범위가 통째로 지워짐
|
||
sheetId = found.시트;
|
||
range = found.범위;
|
||
} else {
|
||
const parsed = parseRange(ask.범위);
|
||
if (!parsed) throw new Error(`범위 꼴 아님 「${ask.범위}」`);
|
||
range = parsed;
|
||
}
|
||
const sheet =
|
||
book.시트.find((s) => s.id === sheetId) ?? book.시트.find((s) => s.이름 === sheetId);
|
||
if (!sheet) throw new Error(`없는 시트 「${sheetId}」`);
|
||
const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT;
|
||
const limit = ask.한도 ?? LIMIT;
|
||
const span = (a: number, b: number): number[] =>
|
||
Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||
const rowShown = (r: number): boolean => {
|
||
const info = sheet.행?.[String(r + 1)];
|
||
return !info?.숨김 && !info?.걸러짐;
|
||
};
|
||
const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김;
|
||
if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4)
|
||
throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음
|
||
const rows = span(range.r0, range.r1).filter(rowShown);
|
||
const cols = span(range.c0, range.c1).filter(colShown);
|
||
if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음");
|
||
if (rows.length * cols.length > limit)
|
||
throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`);
|
||
const engine = createCalcEngine(book);
|
||
const merges: { whole: CellRange; part: CellRange }[] = [];
|
||
for (const text of sheet.병합 ?? []) {
|
||
const whole = parseRange(text);
|
||
if (!whole || !rangesOverlap(whole, range)) continue;
|
||
const part = {
|
||
r0: Math.max(whole.r0, range.r0),
|
||
c0: Math.max(whole.c0, range.c0),
|
||
r1: Math.min(whole.r1, range.r1),
|
||
c1: Math.min(whole.c1, range.c1),
|
||
}; // 범위 밖 병합은 잘라 냄
|
||
merges.push({ whole, part });
|
||
}
|
||
const mergeAt = (r: number, c: number) =>
|
||
merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1);
|
||
const 값: Record<string, string> = {};
|
||
const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => {
|
||
const style = cellStyleAt(book, sheet, at.r, at.c);
|
||
const has = sheet.칸[toA1(at.r, at.c)];
|
||
const shown =
|
||
has && (has.값 !== undefined || has.식 !== undefined)
|
||
? formatValue(engine.value(sheet.id, at.r, at.c), style.형식)
|
||
: { 글: "", 정렬: "left" as const };
|
||
const key = `${sheet.id}!${toA1(r, c)}`;
|
||
값[key] = shown.글;
|
||
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
|
||
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
||
};
|
||
const cells = rows.map((r) =>
|
||
cols.map((c): TableCell | null => {
|
||
const merge = mergeAt(r, c);
|
||
if (!merge) return cellOf(r, c, { r, c });
|
||
const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1);
|
||
const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1);
|
||
if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리
|
||
const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸
|
||
return {
|
||
...cell,
|
||
...(inCols.length > 1 ? { colSpan: inCols.length } : {}),
|
||
...(inRows.length > 1 ? { rowSpan: inRows.length } : {}),
|
||
};
|
||
}),
|
||
);
|
||
const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS;
|
||
const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
|
||
const 표 = {
|
||
type: "Table",
|
||
lineWidth: 1,
|
||
shapeData: {
|
||
origin: { x: 0, y: 0 },
|
||
columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K),
|
||
rowHeights: rows.map(
|
||
(r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K,
|
||
),
|
||
cells,
|
||
style: { fontSize: font, padding: 1, 연결: "" },
|
||
},
|
||
};
|
||
return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 };
|
||
}
|