/* ============================================================================= * B06_Section_UI_Cross_Detail_Cad.ts * 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1). * * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 * (`_Engine_Cad_Table.cross_note_cad_entities`). * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음 * (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 = * 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면. * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` · * `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 * 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_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 { fetchStructures, fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon"; import { readGroupItems, readSummaryItem, summaryLabel, type SummaryItem, } from "../common_util/common_util_summary_item"; import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch"; import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const"; 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)] : [], ); } /* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */ /** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */ const wholeMetres = (value: number | null | undefined): string => typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : ""; /** 측점 이름(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)}`; } /** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */ export interface NoteThing { name: string; /** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */ pipe: boolean; /** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */ at: number; summary_item?: unknown; group_items?: unknown; /** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */ optionLines?: string[]; } /** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */ const PIPE_BODY_LINK = "grv6lny"; /** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */ export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] => things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M); /** * 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 + * 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 · * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1). */ export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] { return thingsAt(things, chainageM).flatMap((thing) => { const own = readSummaryItem(thing.summary_item); const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items)); if (own) slots.unshift(["", own]); const names = slots .filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK))) .map(([, item]) => summaryLabel(item)); const combos = slots.length ? [...new Set(names)] : thing.pipe ? [] : [`${thing.name} 조합 미지정`]; return [...combos, ...(thing.optionLines ?? [])]; }); } function shape(type: string, shapeData: Record): Record { return { id: newId(), type, lineColor: CULVERT_COLOR, lineWidth: 1, layerId: LAYER_ID, shapeData, }; } /** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · * 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */ function pipeMark(section: CrossSection): 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=${wholeMetres(section.design?.pipe_length_m)}`, ]; return [ shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169) ...inside.map((line, index) => textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), ), ]; } /** * 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) · * 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록. */ export function noteEntities( section: CrossSection, station: string, lines: readonly string[], ): Record[] { const mark = pipeMark(section); if (!mark.length && !lines.length) return []; const x = mark.length ? 15 : 0; return [ ...mark, ...[station, ...lines].map((line, index) => textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR), ), ]; } /** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) · * 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */ export async function loadNoteThings(projectId: string): Promise { try { const [pipes, structures, types] = await Promise.all([ readPendingPipes(projectId) ?? fetchDetailPipePoints(projectId).then((res) => res.pipe_points), readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures), fetchStructureTypes(true), ]); const byId = new Map(types.map((type) => [type.type_id, type])); const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind; const optionLines = (type_id: string, options?: Record): string[] => structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id)); const piped = pipes.map((point): NoteThing => { const kind = point.facility ?? "pipe"; const { summary_item, group_items } = point; return { name: nameOf(kind), pipe: kind === "pipe", at: point.chainage_m, summary_item, group_items, optionLines: optionLines(kind, point.options as Record), }; }); const placed = structures.flatMap((item): NoteThing[] => { const at = item.chainage_m ?? item.start_m; if (typeof at !== "number" || !Number.isFinite(at)) return []; const { summary_item, group_items } = item; return [ { name: nameOf(item.type_id), pipe: false, at, summary_item, group_items, optionLines: optionLines(item.type_id, item.options), }, ]; }); return [...piped, ...placed]; } catch { return []; } } /** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면. * 옮기거나 넣었으면 초안에 씀. */ 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) { const lines = structureNoteLines(await at.things, section.chainage_m); entities = noteEntities(section, at.station, lines); } 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; /** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */ things: Promise; } /** * [상세] 모달 아래 칸(`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(); }, }; }