Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts
T

545 lines
22 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Drawing.ts
* M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3).
*
* 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})`
* → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다.
* `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다.
*
* 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` ·
* `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다.
* ========================================================================== */
import "./M02_MasterTemplete_Drawing.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */
export interface DrawingTemplateDoc extends CadHostDrawing {
format?: number;
source?: string;
drawing_area?: [number, number, number, number];
[key: string]: unknown;
}
export interface DrawingTemplateOptions {
/** CAD 안 💾 · Ctrl+S 가 부른다 — 페이지 머리 [저장] 과 같은 일(판 · 409 흐름)을 하게 페이지가 넘긴다. */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
/** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */
layer?: string;
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
projectId?: string | null;
/** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */
onChanged?: (dirty: boolean) => void;
/** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸 · 파일 불러오기를 거기 한 줄로 놓는다. */
headerSlot?: HTMLElement;
/** CAD 좌측 패널(특성 · 도면층)을 접힌 채로 연다 — 누르면 펼침(구조물 도면 미리보기). */
panelsCollapsed?: boolean;
/** 미리보기(집계표 카드) — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
preview?: boolean;
/** 좌표 0,0 기준점 십자를 그린다 — 구조물 도면(편집 · 미리보기)만. */
originMarker?: boolean;
/** 구조물 도면 — 작도 영역 단추 · 자리표(도각 자리) 표시를 없애고, 문서의 작도 영역 값은 무시. */
noArea?: boolean;
/** B07 도각을 외곽 좌하단 (0,0) 에 얹음 — 잠금 층 · 보여주기만(돌려줄 때 뺌 · 저장 안 됨). */
frame?: boolean;
/** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */
noImport?: boolean;
/** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */
sheet?: string;
/** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */
frameNumber?: string;
/** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */
tabs?: HTMLElement;
}
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
function recoveryScopeOf(options: DrawingTemplateOptions, sheet = ""): string {
const name = `${options.name ?? "drawing"}${sheet ? `#${sheet}` : ""}`;
if (!options.layer) return `m02:${name}`;
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
return `m02:${options.layer}${project}:${name}`;
}
/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */
function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void {
const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`;
if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return;
try {
const value = localStorage.getItem(old);
if (value === null) return;
if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) {
localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value);
}
localStorage.removeItem(old);
} catch {
// 저장소를 못 써도 편집은 됨
}
}
export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
placeTable: (entity: Record<string, unknown>, link: string) => void;
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
/** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */
loadSheet: (
drawing: DrawingTemplateDoc,
sheet: string,
frameNumber?: string,
after?: () => void,
) => Promise<void>;
destroy: () => void;
}
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
/** 얹은 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 · 돌려줄 때 이 층을 통째로 뺌 */
const FRAME_LAYER_ID = "m02-structure-frame";
interface StructureFrame {
entities: Record<string, unknown>[];
layer: Record<string, unknown> | null;
}
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
const framePromises = new Map<string, Promise<StructureFrame | null>>();
/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */
function fetchFrame(number = ""): Promise<StructureFrame | null> {
const query = number ? `?number=${encodeURIComponent(number)}` : "";
const got =
framePromises.get(number) ??
fetch(`${API_BASE_URL}/m02/structure-frame${query}`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromises.delete(number); // 다음에 다시 받음
return frame?.layer ? frame : null;
});
framePromises.set(number, got);
return got;
}
/** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */
function withoutFrame(drawing: DrawingTemplateDoc): DrawingTemplateDoc {
return {
...drawing,
entities: drawing.entities.filter((e) => e.layerId !== FRAME_LAYER_ID),
layers: drawing.layers?.filter((layer) => layer.id !== FRAME_LAYER_ID),
};
}
async function importDrawingFile(
file: File,
): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> {
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, {
method: "POST",
credentials: "include",
body: form,
});
const payload = (await response.json()) as {
drawing: DrawingTemplateDoc;
entity_count: number;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function mountDrawingTemplate(
container: HTMLElement,
doc: DrawingTemplateDoc,
options: DrawingTemplateOptions = {},
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const noArea = options.noArea === true;
let recoveryScope = recoveryScopeOf(options, options.sheet);
let frameNumber = options.frameNumber ?? "";
migrateOldRecovery(options, recoveryScopeOf(options));
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
root.className = "m02-drawing";
const toolbar = document.createElement("div");
toolbar.className = "m02-drawing__toolbar";
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. 제목 줄에는 값이 다 보이는 단추 하나,
// 누르면 칸 넷이 작은 창으로 펼쳐진다(칸은 창을 닫아도 그대로 살아 있음).
const areaForm = document.createElement("div");
areaForm.className = "m02-drawing__areaform";
const start = doc.drawing_area ?? DEFAULT_AREA;
const areaInputs = AREA_LABELS.map((label, index) => {
const field = createInputField({ label, type: "number", value: String(start[index]) });
field.input.disabled = readOnly;
field.input.addEventListener("input", () => {
options.onChanged?.(true);
readArea();
});
return field;
});
areaForm.append(...areaInputs.map((field) => field.root));
const areaButton = createButton({ label: "", variant: "ghost" });
areaButton.classList.add("m02-drawing__areabtn");
areaButton.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm) · 눌러서 고침";
let areaModal: { close: () => void } | null = null;
const readArea = (): [number, number, number, number] | null => {
const values = areaInputs.map((field) => Number(field.input.value));
const valid =
areaInputs.every((field) => field.input.value.trim() !== "") &&
values.every(Number.isFinite) &&
values[0] < values[2] &&
values[1] < values[3];
// 빈 칸 · 숫자 아님은 그 칸만 짚음 — 모두 숫자인데 어긋나면 크기 순서 안내
const own = areaInputs.map((field) => {
const raw = field.input.value.trim();
return raw === "" ? "값을 넣을 것" : Number.isFinite(Number(raw)) ? undefined : "숫자만";
});
const order = own.some(Boolean) ? undefined : "왼쪽<오른쪽 · 아래<위";
areaInputs.forEach((field, index) => field.setError(valid ? undefined : (own[index] ?? order)));
areaButton.textContent = `작도 영역 ${areaInputs.map((f) => f.input.value.trim() || "?").join(" · ")}`;
areaButton.classList.toggle("is-invalid", !valid);
return valid ? (values as [number, number, number, number]) : null;
};
readArea();
areaButton.addEventListener("click", () => {
areaModal?.close();
areaModal = openModal({
title: "작도 영역",
closeLabel: "닫기",
dialogClass: "m02-modal",
backdropClass: "m02-pop",
mount: (body) => body.append(areaForm),
});
// 단추 바로 아래 · 오른쪽 맞춤 · 화면 밖으로 안 나가게
const dialog = document.querySelector<HTMLElement>(".m02-pop > .ui-modal");
if (!dialog) return;
const at = areaButton.getBoundingClientRect();
const width = dialog.offsetWidth;
dialog.style.top = `${at.bottom + 4}px`;
dialog.style.left = `${Math.max(8, Math.min(at.right - width, innerWidth - width - 8))}px`;
dialog.style.maxHeight = `${innerHeight - at.bottom - 12}px`;
});
// CAD 안 💾 · Ctrl+S — 양식 파일은 페이지 [저장] 만 쓴다. 저장 중 또 눌러도 한 번만 부른다.
let saving = false;
const hostSave = (): void => {
if (readOnly) return void showToast("이 양식은 볼 수만 있습니다.", "info");
if (saving || !options.onSave) return;
saving = true;
getDoc()
.then((next) => options.onSave?.(next))
.catch((error) =>
showToast(error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", "error"),
)
.finally(() => (saving = false));
};
const cad = createCadHost<DrawingTemplateDoc>({
title: "도면 양식",
onChanged: (dirty) => options.onChanged?.(dirty),
onHostSave: hostSave,
});
let loadSeq = 0;
/** `after` = 싣기를 보낸 바로 뒤(이 싣기가 마지막일 때만) — 표 놓기를 「열린 뒤」로 미룰 때 */
const load = async (drawing: DrawingTemplateDoc, after?: () => void): Promise<void> => {
const mine = ++loadSeq;
cad.beginLoading();
const frame = options.frame ? await fetchFrame(frameNumber) : null;
if (mine !== loadSeq) return; // 그새 다른 도면을 실음
const own = withoutFrame(drawing);
const shown = frame?.layer
? {
...own,
entities: [...own.entities, ...frame.entities],
// 층 없는 빈 문서 — 잠금 도각 층만 남으면 새 그림이 도각 층에 떨어짐 · 기본 층(서버 `_BLANK_LAYER`)을 채움
layers: [...(own.layers?.length ? own.layers : [BLANK_LAYER]), frame.layer],
}
: drawing;
cad.load(
shown,
null,
!readOnly && !noArea,
{},
{
recoveryScope,
readOnly,
hostTitle: "도면 양식 편집",
hostSave: true,
ribbonCollapsed: true,
panelsCollapsed: options.panelsCollapsed,
viewOnly: options.preview === true,
originMarker: options.originMarker === true,
},
);
after?.();
};
const actions = document.createElement("div");
actions.className = "m02-drawing__actions";
const canImport = !readOnly && options.noImport !== true;
if (canImport) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".dxf,.dwg";
fileInput.hidden = true;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
fileInput.value = "";
if (!file) return;
importButton.disabled = true;
importDrawingFile(file)
.then((response) => {
// 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다.
void load(response.drawing);
options.onChanged?.(true); // 불러온 것은 아직 저장 전 — 떠날 때 묻게 함
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
})
.catch((error) =>
showToast(
error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.",
"error",
),
)
.finally(() => (importButton.disabled = false));
});
const importButton = createButton({
label: "📂",
variant: "ghost",
onClick: () => fileInput.click(),
});
importButton.title = importButton.ariaLabel = "파일 불러오기 (DXF · DWG)";
actions.append(importButton, fileInput);
}
const getDoc = async (): Promise<DrawingTemplateDoc> => {
const { drawing: saved } = await cad.requestSave();
const drawing = options.frame ? withoutFrame(saved) : saved;
if (noArea) {
base = { ...base, ...drawing };
return base;
}
const drawingArea = readArea();
if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다.");
base = { ...base, ...drawing, drawing_area: drawingArea };
return base;
};
// 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게.
toolbar.append(...(noArea ? [] : [areaButton]), actions);
const tabs = options.tabs ? [options.tabs] : [];
if (options.headerSlot) {
toolbar.classList.add("m02-drawing__toolbar--slot");
options.headerSlot.append(toolbar);
root.append(...tabs, cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element);
container.append(root);
void load(doc);
return {
getDoc,
placeTable: cad.placeTable,
updateTable: cad.updateTable,
loadSheet: (drawing, sheet, number, after) => {
recoveryScope = recoveryScopeOf(options, sheet);
frameNumber = number ?? "";
base = drawing;
return load(drawing, after);
},
destroy: () => {
areaModal?.close();
cad.destroy();
toolbar.remove();
root.remove();
},
};
}
/* --- 구조물 도면 여러 장(82-1 · 82-2) — 한 파일 `도면`(1장) + `더한장[]` · 탭 줄 = 공용 `cad_host_tabs` --- */
/** 구조물 도면 더한 장 — `M02_MasterTemplete_Api_Fetch` `StructureSheet` 와 같은 꼴 */
interface SheetDoc {
장id: string;
이름: string;
도면: DrawingTemplateDoc;
}
/** 구조물 도면 문서에서 쓰는 칸 */
interface SheetsDoc {
도번?: string;
도면: DrawingTemplateDoc;
더한장?: SheetDoc[];
}
const FIRST_TAB = "#1"; // 1장 탭 id — 장id 는 「#」 를 안 씀(저장은 빈 값)
const FIRST_NAME = "1장";
/** 장 도번 — 1장 = 도번 · n 째 장 = 도번-n(SD-000010-2) */
export const sheetNumber = (number: string | undefined, index: number): string =>
!number ? "" : index === 0 ? number : `${number}-${index + 1}`;
/** 표 칸 갈기 — 안 실린 장(받아 둔 편집본)에서 · 크기는 칸 수가 바뀔 때만(서버 `refresh_drawing` 과 같음) */
function updateStashedTables(
drawing: DrawingTemplateDoc,
link: string,
cells: unknown[][],
): number {
let count = 0;
for (const entity of drawing.entities) {
const shape = (entity as { type?: string; shapeData?: Record<string, unknown> }).shapeData;
const style = shape?.style as Record<string, unknown> | undefined;
if (entity.type !== "Table" || !shape || style?.연결 !== link) continue;
shape.cells = cells;
count += 1;
}
return count;
}
export interface StructureSheetsHandle extends Omit<DrawingTemplateHandle, "getDoc" | "loadSheet"> {
/** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */
getDoc: () => Promise<{ 도면: DrawingTemplateDoc; 더한장?: SheetDoc[] }>;
/** 지금 탭 장id(1장 = 빈 값) */
activeSheet: () => string;
}
/** 구조물 도면 상세 — 탭 줄 + 한 CAD · 탭 바꾸면 지금 장을 받아 두고 다음 장을 실음 */
export function mountStructureSheets(
container: HTMLElement,
doc: SheetsDoc,
options: DrawingTemplateOptions = {},
): StructureSheetsHandle {
const readOnly = options.readOnly === true;
const more = doc.더한장 ?? [];
let cadDirty = false;
let listDirty = false;
/** 아직 안 놓은 보낸 표(82-3) — 놓기 전에 탭을 바꾸면 그 장에 다시 걺(놓을 장 = 지금 탭) */
let placing: { entity: Record<string, unknown>; link: string } | null = null;
const placed = (got: DrawingTemplateDoc, link: string): boolean =>
got.entities.some((e) => {
const style = (e as { shapeData?: { style?: Record<string, unknown> } }).shapeData?.style;
return e.type === "Table" && style?.연결 === link;
});
const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty());
const numberOf = (id: string): string =>
sheetNumber(
doc.도번,
tabs.tabs().findIndex((tab) => tab.id === id),
);
const tabs: CadTabs<DrawingTemplateDoc> = createCadTabs<DrawingTemplateDoc>({
features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true },
capture: async () => {
const got = await drawing.getDoc();
if (placing && placed(got, placing.link)) placing = null;
return { drawing: got, dirty: cadDirty };
},
show: async (tab) => {
cadDirty = false;
// 놓기는 싣기를 보낸 바로 뒤 — cad_host 가 「열린 뒤」로 미룸(먼저 보내면 싣기가 도구를 끔)
await drawing.loadSheet(
tab.drawing ?? { entities: [], layers: [BLANK_LAYER] },
tab.id === FIRST_TAB ? "" : tab.id,
numberOf(tab.id),
() => placing && drawing.placeTable(placing.entity, placing.link),
);
report();
},
create: (): CadTab<DrawingTemplateDoc> => {
const names = new Set(tabs.tabs().map((tab) => tab.name));
let n = tabs.tabs().length + 1;
while (names.has(`${n}장`)) n += 1;
const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return {
id,
name: `${n}장`,
drawing: { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
};
},
onChange: () => {
listDirty = true;
retitle();
report();
},
});
const retitle = (): void => {
for (const tab of tabs.tabs()) tab.title = numberOf(tab.id);
};
tabs.set(
[
{ id: FIRST_TAB, name: FIRST_NAME, drawing: doc.도면, fixed: true },
...more.map((sheet) => ({ id: sheet.장id, name: sheet.이름, drawing: sheet.도면 })),
],
FIRST_TAB,
);
retitle();
tabs.set(tabs.tabs(), FIRST_TAB); // 풍선 글을 그림
if (readOnly && !more.length) tabs.element.hidden = true; // 볼 장이 하나 — 줄 없음
const drawing = mountDrawingTemplate(container, doc.도면, {
...options,
sheet: "",
frameNumber: doc.도번,
tabs: tabs.element,
onChanged: (dirty) => {
cadDirty = dirty;
if (dirty && !readOnly) tabs.markDirty();
report();
},
});
return {
getDoc: async () => {
const all = await tabs.collect();
const first = all.find((tab) => tab.id === FIRST_TAB);
const rest = all
.filter((tab) => tab.id !== FIRST_TAB)
.map((tab) => ({
장id: tab.id,
이름: tab.name,
도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
}));
listDirty = false;
cadDirty = false; // 받기(requestSave)가 CAD 미저장도 내림 — 다음 고침이 다시 알림
tabs.markClean(undefined, true);
return {
도면: first?.drawing ?? doc.도면,
...(rest.length || doc.더한장 ? { 더한장: rest } : {}),
};
},
activeSheet: () => {
const id = tabs.active()?.id ?? FIRST_TAB;
return id === FIRST_TAB ? "" : id;
},
placeTable: (entity, link) => {
placing = { entity, link };
drawing.placeTable(entity, link);
},
// 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3)
updateTable: async (link, cells) => {
const now = tabs.active();
let count = 0;
for (const tab of tabs.tabs()) {
if (tab === now || !tab.drawing) continue;
const changed = updateStashedTables(tab.drawing, link, cells);
if (changed) tab.dirty = true;
count += changed;
}
if (count) report();
return count + (await drawing.updateTable(link, cells).catch(() => 0));
},
destroy: drawing.destroy,
};
}