/* ============================================================================= * B07_DesignDetail_Cross_Extent_Node.ts * 횡단도 **덧그림 범위**를 서버가 재는 Node 진입점(68-3, CLAUDE.md 5장 「계산 자리」 ②). * * 화면은 B06 산식으로 설계선·구조물을 덧그린다(`_Cross_Overlay`). 서버 장 배치가 그 * 범위를 모르면 박스가 그림보다 작아 가장자리에서 잘렸다. 화면과 **같은 코드**를 * 오프스크린 SVG 대신 아래 최소 DOM 위에서 돌려 측점마다 범위(m)를 낸다. * * 실행: node <번들> <입력.json> <출력.json> * 입력 { detail: 종횡단 상세(API와 같은 꼴) } * 출력 { extents: [{ chainage_m, x_min, x_max, y_min, y_max }] } — 오프셋·표고(m) * 끝 코드: 0 성공 / 2 인자 오류 * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay"; /** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */ const MM_PER_M = 10; const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12]; /* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ type Child = FakeElement | string; /** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ function matches(element: FakeElement, selectors: string): boolean { return selectors.split(",").some((raw) => { const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); if (!found) return false; const [, tag, attribute] = found; if (tag && element.tagName !== tag) return false; return !attribute || element.attributes.has(attribute); }); } class FakeElement { readonly attributes = new Map(); readonly childNodes: Child[] = []; parentNode: FakeElement | null = null; readonly dataset: Record = {}; readonly style: Record = { setProperty: () => undefined }; readonly classList = { add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), remove: (...names: string[]) => this.setClasses(this.classes().filter((name) => !names.includes(name))), toggle: (name: string, force?: boolean) => { const on = force ?? !this.classes().includes(name); this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); return on; }, contains: (name: string) => this.classes().includes(name), }; constructor(readonly tagName: string) {} get ownerDocument(): typeof fakeDocument { return fakeDocument; } get parentElement(): FakeElement | null { return this.parentNode; } get children(): FakeElement[] { return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); } get firstChild(): Child | null { return this.childNodes[0] ?? null; } get lastChild(): Child | null { return this.childNodes[this.childNodes.length - 1] ?? null; } get textContent(): string { return this.childNodes .map((child) => (typeof child === "string" ? child : child.textContent)) .join(""); } set textContent(value: string | null) { this.replaceChildren(...(value ? [String(value)] : [])); } private classes(): string[] { return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); } private setClasses(names: string[]): void { this.attributes.set("class", [...new Set(names)].join(" ")); } private adopt(child: Child): Child { if (typeof child !== "string") { child.parentNode?.removeChild(child); child.parentNode = this; } return child; } setAttribute(name: string, value: unknown): void { this.attributes.set(name, String(value)); } setAttributeNS(_ns: string | null, name: string, value: unknown): void { this.setAttribute(name, value); } getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; } hasAttribute(name: string): boolean { return this.attributes.has(name); } removeAttribute(name: string): void { this.attributes.delete(name); } append(...nodes: Child[]): void { for (const node of nodes) this.childNodes.push(this.adopt(node)); } appendChild(node: T): T { this.append(node); return node; } prepend(...nodes: Child[]): void { this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); } insertBefore(node: T, reference: Child | null): T { const index = reference ? this.childNodes.indexOf(reference) : -1; if (index < 0) return this.appendChild(node); this.childNodes.splice(index, 0, this.adopt(node)); return node; } removeChild(node: T): T { const index = this.childNodes.indexOf(node); if (index >= 0) this.childNodes.splice(index, 1); if (typeof node !== "string") node.parentNode = null; return node; } replaceChildren(...nodes: Child[]): void { for (const child of [...this.childNodes]) this.removeChild(child); this.append(...nodes); } remove(): void { this.parentNode?.removeChild(this); } closest(selectors: string): FakeElement | null { for (let node: FakeElement | null = this; node; node = node.parentNode) { if (matches(node, selectors)) return node; } return null; } querySelectorAll(selectors: string): FakeElement[] { const found: FakeElement[] = []; const walk = (node: FakeElement): void => { for (const child of node.children) { if (matches(child, selectors)) found.push(child); walk(child); } }; walk(this); return found; } querySelector(selectors: string): FakeElement | null { return this.querySelectorAll(selectors)[0] ?? null; } addEventListener(): void {} removeEventListener(): void {} } const fakeDocument = { createElementNS: (_ns: string, tag: string) => new FakeElement(tag), createElement: (tag: string) => new FakeElement(tag), createTextNode: (text: string) => text, // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). getElementById: () => null, }; (globalThis as Record).document = fakeDocument; /* ── 범위 재기 ─────────────────────────────────────────────────────────── */ /** * 글자 폭(종이 mm) 어림 — Node 에는 글꼴이 없다. 한글은 글자 크기만큼, 나머지는 0.6 배로 * 잡는다(sans-serif 실측: 한글 0.92 · 숫자 0.56 · 기호 0.28~0.56 · m 0.93). 넉넉한 쪽이라 글자가 박스 * 끝에 걸리면 간격이 1 mm 보다 조금 넓게 나온다. */ function textWidth(label: string, fontSize: number): number { let width = 0; for (const char of label) width += /[ㄱ-힣]/.test(char) ? fontSize : fontSize * 0.6; return width; } function extentOf(entities: Entity[]): [number, number, number, number] | null { const xs: number[] = []; const ys: number[] = []; const walk = (node: Entity): void => { const shape = (node.shapeData ?? null) as Record | null; const label = shape?.label; const base = shape?.basePoint as { x: number; y: number } | undefined; if (typeof label === "string" && base) { // CAD 는 글자를 기준점 세로 가운데(textBaseline middle)에 정렬 쪽으로 그린다. const options = (shape?.options ?? {}) as { fontSize?: number; textAlign?: string }; const size = Number(options.fontSize ?? 2); const width = textWidth(label, size); const left = options.textAlign === "center" ? base.x - width / 2 : options.textAlign === "right" ? base.x - width : base.x; xs.push(left, left + width); ys.push(base.y - size / 2, base.y + size / 2); } for (const key of ["startPoint", "endPoint", "basePoint"]) { const point = shape?.[key] as { x: number; y: number } | undefined; if (point) { xs.push(point.x); ys.push(point.y); } } const center = shape?.center as { x: number; y: number } | undefined; if (center) { const radius = Number(shape?.radius ?? 0); xs.push(center.x - radius, center.x + radius); ys.push(center.y - radius, center.y + radius); } for (const child of (node.children as Entity[] | undefined) ?? []) walk(child); }; entities.forEach(walk); if (!xs.length) return null; return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; } const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { console.error("사용법: node <번들> <입력.json> <출력.json>"); process.exit(2); } const input = JSON.parse(readFileSync(inputPath, "utf8")) as { detail?: SectionDetailResponse }; const sections = input.detail?.cross_sections ?? []; const extents: Record[] = []; for (const section of sections) { let entities: Entity[] | null = null; try { entities = overlayEntities( section, sections, (offset) => offset * MM_PER_M, (elevation) => -(elevation * MM_PER_M), OPEN_FRAME, ); } catch (error) { // 한 측점의 기하 실패가 전체를 막지 않는다 — 화면도 그 측점만 건너뛴다. console.error(`측점 ${section.chainage_m} 덧그림 실패: ${String(error)}`); } const extent = entities?.length ? extentOf(entities) : null; if (!extent) continue; const [xMin, xMax, yMin, yMax] = extent.map((value) => value / MM_PER_M); extents.push({ chainage_m: section.chainage_m, x_min: xMin, x_max: xMax, y_min: yMin, y_max: yMax, }); } writeFileSync(outputPath, JSON.stringify({ extents }), "utf8");