/* ============================================================================= * B06_Section_UI_Cross_Detail_Cad.ts * 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21). * * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 * (`_Engine_Cad_Table.cross_note_cad_entities`). * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` · * `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 · * 거울 시험 `test_260_note_cad.py`. * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. * ========================================================================== */ import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; import { readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; const noteKey = (chainageM: number): string => chainageM.toFixed(2); /** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */ export function readStructureNoteCads( projectId: string, routeId: number, ): Map { const raw = readState>("structure-note-cads", projectId, routeId); return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); } function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 if (section.design) section.design.structure_note_cad = cad; const all = readState>( "structure-note-cads", at.projectId, at.routeId, ); const next = { ...all, [noteKey(section.chainage_m)]: cad }; writeState("structure-note-cads", next, at.projectId, at.routeId); } /** 세션 초안 → 저장분 → 없음(null). */ function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null { const draft = readState>( "structure-note-cads", at.projectId, at.routeId, ); return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null; } /* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */ const LAYER_ID = "기본"; const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false }; const FONT = 2.0; const LINE = 3.0; /** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */ const NOTE_COLOR = "#e8edf4"; const CULVERT_COLOR = "#ff7f7f"; const newId = (): string => crypto.randomUUID(); function textEntity( label: string, x: number, y: number, color: string, align: "left" | "center" = "left", ): Record { return { id: newId(), type: "Text", lineColor: color, lineWidth: 1, layerId: LAYER_ID, shapeData: { label, basePoint: { x, y }, options: { textDirection: { x: 1, y: 0 }, textAlign: align, textColor: color, fontSize: FONT, fontFamily: "sans-serif", }, }, }; } /** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */ export function legacyNoteEntities(note: string | undefined): Record[] { const lines = (note ?? "") .trim() .split(/\r?\n/) .map((line) => line.trimEnd()); if (!lines.some(Boolean)) return []; return lines.flatMap((line, index) => line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [], ); } /* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */ const one = (value: number | null | undefined): string => typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); const trim = (value: number): string => String(Math.round(value * 100) / 100); /** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ const bondOf = (form: string | null | undefined): string => /찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? ""); const BASIN_FORMS: Record = { I: "I형", L: "L형", U: "ㄷ형" }; /** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */ function sideLine(side: CulvertSideSpec | undefined, title: string): string | null { if (!side?.structure) return null; const basin = side.structure === "집수정"; const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form); const height = basin ? side.basin_height_m : side.revet_height_m; const length = basin ? side.basin_length_m : side.revet_length_m; const spec = [form, `H=${one(height)}`].filter(Boolean).join(","); return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`; } /** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */ function chuteLine(options: Record, fallback?: string): string | null { const picked = Object.keys(options) .map((key) => /^chute_add_(\d+)_structure$/.exec(key)) .filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim()) .sort((a, b) => Number(a[1]) - Number(b[1]))[0]; const head = picked ? `chute_add_${picked[1]}_` : null; const structure = head ? String(options[`${head}structure`]).trim() : fallback; if (!structure) return null; const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : ""; const area = head ? Number(options[`${head}area`]) : NaN; const spec = [structure, bond].filter(Boolean).join(","); return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`; } /** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */ export function stationNoLabel(chainageM: number, intervalM: number): string { const safe = intervalM > 0 ? intervalM : 1; let number = Math.floor((chainageM + 1e-6) / safe); let rest = chainageM - number * safe; if (rest >= safe - 0.05) { number += 1; rest = 0; } return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; } function shape(type: string, shapeData: Record): Record { return { id: newId(), type, lineColor: CULVERT_COLOR, lineWidth: 1, layerId: LAYER_ID, shapeData, }; } /** * 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관 * (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적). * 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) · * 관유출부보강. */ export function culvertNoteEntities( section: CrossSection, station: string, options: Record = {}, ): Record[] { const culvert = section.culvert; if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return []; if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M) return []; const [cx, cy, radius] = [6.5, -9.0, 6.5]; const inside = [ "배수관", `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, `L=${metres(section.design?.pipe_length_m)}`, ]; const right = [ station, sideLine(culvert.inlet, "유입"), sideLine(culvert.outlet, "유출"), chuteLine(options, culvert.chute?.structure), ].filter((line): line is string => !!line); return [ shape("Circle", { center: { x: cx, y: cy }, radius }), shape("Line", { startPoint: { x: cx, y: cy - radius }, endPoint: { x: cx, y: cy - radius - LINE }, }), ...inside.map((line, index) => textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), ), ...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)), ]; } /** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */ async function pipeOptionsAt( projectId: string, chainageM: number, ): Promise> { const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points; const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M); return pipe?.options ?? {}; } /** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */ async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise { const stored = noteCadOf(section, at); if (stored) return stored; let entities = legacyNoteEntities(section.design?.structure_note); if (!entities.length && section.culvert) { const owner = section.culvert.chainage_m ?? section.chainage_m; const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({})); entities = culvertNoteEntities(section, at.station, options); } const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; if (entities.length) writeNoteCad(section, cad, at); return cad; } /* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */ export interface NoteCadPlace { projectId: string; routeId: number; /** 미리 넣음 첫 줄(`stationNoLabel`). */ station: string; } /** * [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). */ export function mountNoteCad( box: HTMLElement, first: CrossSection, section: () => CrossSection, at: NoteCadPlace, ): { destroy: () => void } { let closed = false; const cad = createCadHost({ title: "표 위 CAD", onChanged: (dirty) => { if (!dirty || closed) return; cad .requestSave() .then(({ drawing }) => { if (!closed) writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at); }) .catch(() => undefined); }, }); box.append(cad.element); cad.beginLoading(); const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`; void firstNoteCad(first, at).then((drawing) => { if (closed) return; const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER]; cad.load( { entities: drawing.entities, layers }, null, false, {}, { recoveryScope: scope, hostTitle: "표 위 CAD", bare: true, }, ); }); return { destroy: () => { closed = true; cad.destroy(); }, }; }