feat(M02): 면적 도면 패널 고르기 · [면적산출로 보내기] · 표 자료 → 새 시트 자료 변환 · 시트 2장 이상 탭 줄(83-3 앞부분 · addSheet 손잡이 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-08 12:19:56 +09:00
co-authored by Claude Opus 5.5
parent 4e9df0e4c9
commit c7341ef484
7 changed files with 593 additions and 11 deletions
@@ -1,19 +1,51 @@
/* =============================================================================
* M02_MasterTemplete_AreaDrawing.ts
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동).
* 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`).
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기 · 고르기(고치기 없음).
* 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음.
* 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료
* (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽).
* CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard";
import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing";
import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
const TEXT = {
loading: "도면 불러오는 중…",
none: "이 열은 구조물 도면이 없음",
send: "면적산출로 보내기",
sendTip:
"도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 면적산출 새 시트로 한 번 옮김 — 연동 아님 · 누름 · 끌기로 고름",
};
export const EXPORT_TABLE_REQUEST = "aislo:b08:export-table-request";
export const EXPORT_TABLE = "aislo:b08:export-table";
/** 고르기만 — 제목줄 · 리본 · 패널 · 상태줄 숨김(카드 고치기 갈래와 같은 숨김 · 명령 안내 줄은 남김) */
const HIDE =
".cad-titlebar, .cad-ribbon, .cad-inspector, .cad-statusbar, .cad-quick-access" +
" { display: none !important; }";
function hideChrome(frame: HTMLIFrameElement): void {
const apply = (): void => {
const inner = frame.contentDocument;
if (!inner?.head || inner.getElementById("m02-area-hide")) return;
const style = inner.createElement("style");
style.id = "m02-area-hide";
style.textContent = HIDE;
inner.head.append(style);
};
frame.addEventListener("load", apply);
apply();
}
export interface AreaDrawingOptions {
/** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */
onTable?: (table: DrawingTable) => void;
}
export interface AreaDrawing {
root: HTMLElement;
/** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */
@@ -21,13 +53,37 @@ export interface AreaDrawing {
destroy: () => void;
}
export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
export function createAreaDrawing(
layer: Layer,
column: string,
opts: AreaDrawingOptions = {},
): AreaDrawing {
const root = el("div", { className: "m02-apanel__drawing" });
const stage = el("div", { className: "m02-apanel__drawstage" });
let view: { destroy: () => void } | null = null;
let frame: HTMLIFrameElement | null = null;
let started = false;
let dead = false;
const note = (text: string): void =>
root.replaceChildren(el("p", { className: "m02-apanel__note", text }));
stage.replaceChildren(el("p", { className: "m02-apanel__note", text }));
const send = createButton({ label: TEXT.send, size: "sm", variant: "ghost" });
send.title = TEXT.sendTip;
send.dataset.areaFromDrawing = "";
send.disabled = true; // 도면이 서면 켬
send.addEventListener("click", () =>
frame?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin),
);
if (opts.onTable) root.append(el("div", { className: "m02-apanel__drawbar", children: [send] }));
root.append(stage);
const onMessage = (event: MessageEvent): void => {
if (!frame || event.source !== frame.contentWindow) return;
if (event.origin !== window.location.origin) return;
const data = event.data as { type?: string; table?: DrawingTable } | null;
if (data?.type === EXPORT_TABLE && data.table && opts.onTable) opts.onTable(data.table);
};
window.addEventListener("message", onMessage);
const show = (): void => {
if (started) return;
@@ -38,8 +94,21 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
if (dead) return;
const doc = got.문서 as StructureDoc | null;
if (!doc?.도면) return note(TEXT.none);
root.replaceChildren();
view = mountDrawingView(root, doc, { layer, projectId: null }, column);
stage.replaceChildren();
// 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀
view = mountDrawingTemplate(stage, doc.도면 as unknown as DrawingTemplateDoc, {
readOnly: true,
name: `preview:${column}`,
layer,
panelsCollapsed: true,
originMarker: true,
noArea: true,
frame: true,
noImport: true,
});
frame = stage.querySelector("iframe");
if (frame) hideChrome(frame);
send.disabled = !frame;
})
.catch(() => !dead && note(TEXT.none));
};
@@ -49,6 +118,7 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
show,
destroy: () => {
dead = true;
window.removeEventListener("message", onMessage);
view?.destroy();
root.remove();
},
@@ -0,0 +1,111 @@
/* =============================================================================
* M02_MasterTemplete_AreaFromDrawing.ts
* 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를
* 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영.
* 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm).
* 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
const TEXT = {
done: (name: string) =>
`도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인",
noHandle: "스프레드시트가 시트 더하기를 아직 못 함 — 옮기지 않음",
noSheet: "면적산출 시트가 열려 있지 않음",
};
/** CAD 가 보낸 표 자료(`DrawingTable`) */
export interface DrawingTable {
kind: "table" | "grid" | "text";
rows: (string | number)[][];
merges: { row: number; col: number; rowSpan: number; colSpan: number }[];
colWidths: number[];
rowHeights: number[];
}
export const SHEET_PREFIX = "도면에서";
/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */
const CHAR_PER_MM = 0.54;
const PT_PER_MM = 72 / 25.4;
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
const round1 = (v: number): number => Math.round(v * 10) / 10;
/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */
function colName(c: number): string {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
}
const a1 = (r: number, c: number): string => `${colName(c)}${r + 1}`;
/** 「도면에서 n」 — 안 쓴 가장 작은 n */
export function nextSheetName(book: Pick<Workbook, "시트">): string {
const used = new Set(book.시트.map((s) => s.이름));
let n = 1;
while (used.has(`${SHEET_PREFIX} ${n}`)) n += 1;
return `${SHEET_PREFIX} ${n}`;
}
/** 안 쓴 시트 id(`s1` …) */
function nextSheetId(book: Pick<Workbook, "시트">): string {
const used = new Set(book.시트.map((s) => s.id));
let n = book.시트.length + 1;
while (used.has(`s${n}`)) n += 1;
return `s${n}`;
}
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */
export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">): Sheet {
const 칸: Record<string, Cell> = {};
table.rows.forEach((line, r) =>
line.forEach((value, c) => {
if (value === "" || value === null || value === undefined) return;
칸[a1(r, c)] = { 값: value };
}),
);
const 병합 = table.merges
.filter((m) => m.rowSpan > 1 || m.colSpan > 1)
.map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`);
const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 };
if (병합.length) sheet.병합 = 병합;
if (table.colWidths.length) {
sheet.열 = {};
table.colWidths.forEach((w, c) => {
if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) };
});
}
if (table.rowHeights.length) {
sheet.행 = {};
table.rowHeights.forEach((h, r) => {
if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) };
});
}
return sheet;
}
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */
export interface AddSheetHandle {
getDoc(): Workbook;
addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown;
}
/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */
export function importDrawingTable(
handle: AddSheetHandle | null,
table: DrawingTable,
): string | null {
if (!handle?.addSheet) {
showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error");
return null;
}
const sheet = tableToSheet(table, handle.getDoc());
handle.addSheet(sheet, { activate: true });
showToast(TEXT.done(sheet.이름), "success");
if (table.kind === "text") showToast(TEXT.loose, "info");
return sheet.이름;
}
@@ -62,3 +62,27 @@
height: min(60vh, 520px);
min-height: 240px;
}
/* 도면 패널 머리 [면적산출로 보내기](83-3) — 아래 도면 칸이 남은 높이를 다 씀 */
.m02-apanel__drawing:has(> .m02-apanel__drawstage) {
flex-direction: column;
}
.m02-apanel__drawbar {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.m02-apanel__drawstage {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.m02-apanel__drawstage .cad-host {
min-height: 0;
}
@@ -2,7 +2,7 @@
* M02_MasterTemplete_AreaPanel.ts
* 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유)
* · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`)
* · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림)
* · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3)
* · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기)
* · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`)
* ========================================================================== */
@@ -12,6 +12,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels";
import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing";
import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
import { openAreaLink } from "./M02_MasterTemplete_AreaLink";
import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View";
import "./M02_MasterTemplete_AreaPanel.css";
@@ -95,7 +96,10 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
});
};
const view = createLaborView(openLink);
const drawing = createAreaDrawing(opts.layer, opts.column);
// 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음
const drawing = createAreaDrawing(opts.layer, opts.column, {
onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table),
});
const panels = createSidePanels({
prefKey: "m02-area-panels",
defaultWidth: DEFAULT_WIDTH,
@@ -287,7 +287,8 @@ export function createMain(
doc as Parameters<typeof createSpreadsheet>[1],
{
readOnly,
tabs: false, // 시트 하나 · 탭 줄 없음
// 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄
tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1,
onChange: () => {
dirty = !readOnly;
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함