- 덧그림 범위를 서버가 화면과 같은 TS 로 셈 — Node 진입점 B07_DesignDetail_Cross_Extent_Node(최소 DOM 위에서 오프스크린 SVG 그리기 그대로) · 번들 build:b07-extent - 화면 그리기·수확 부분을 B07_DesignDetail_Cross_Overlay 로 떼어 화면과 Node 가 한 벌로 씀 - 장 배치·블록 테두리가 측점별 덧그림 범위를 덮음(목록·장 열기·확정·수정 네 길 모두 같은 계획) - 글자는 CAD 그리기대로 세로 가운데 · 폭은 글꼴 어림(한글 1.0 · 나머지 0.6 배) - 번들 낡음 판정에 B07 맨 위 TS 포함 - 검증_sub4: 66개 박스 ↔ 그림(덧그림·글자 실폭 포함) 모두 1.0 mm · 잘림 0 · 겹침 0 · 20장 → 21장 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, string>();
|
|
readonly childNodes: Child[] = [];
|
|
parentNode: FakeElement | null = null;
|
|
readonly dataset: Record<string, string> = {};
|
|
readonly style: Record<string, unknown> = { 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<T extends Child>(node: T): T {
|
|
this.append(node);
|
|
return node;
|
|
}
|
|
prepend(...nodes: Child[]): void {
|
|
this.childNodes.unshift(...nodes.map((node) => this.adopt(node)));
|
|
}
|
|
insertBefore<T extends Child>(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<T extends Child>(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<string, unknown>).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<string, unknown> | 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<string, number>[] = [];
|
|
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");
|