/* ============================================================================= * B06_Section_UI_Cross_Detail.ts * 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4). * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — * 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * ========================================================================== */ import "./B06_Section_UI_Style_Cross_Detail.css"; import { openModal } from "@ui/ui_template_modal"; import { readState, writeState } from "../A00_Common/b_page_state"; import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; import type { CrossQuantitySheet, CrossSection, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick"; import { attachSvgZoomPan, type ZoomPanHandle, type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; /** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */ chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; } let host: CrossDetailHost | null = null; export function setCrossDetailHost(next: CrossDetailHost): void { host = next; } /* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ const noteKey = (chainageM: number): string => chainageM.toFixed(2); /** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ export function readStructureNotes(projectId: string, routeId: number): Map { const raw = readState>("structure-notes", projectId, routeId) ?? {}; return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)])); } function noteOf(section: CrossSection): string { const routeId = host?.routeId(); if (host && routeId !== null && routeId !== undefined) { const draft = readState>("structure-notes", host.projectId, routeId); const text = draft?.[noteKey(section.chainage_m)]; if (text !== undefined) return text; } return section.design?.structure_note ?? ""; } function writeNote(section: CrossSection, text: string): void { // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 if (section.design) section.design.structure_note = text; const routeId = host?.routeId(); if (!host || routeId === null || routeId === undefined) return; const all = readState>("structure-notes", host.projectId, routeId) ?? {}; all[noteKey(section.chainage_m)] = text; writeState("structure-notes", all, host.projectId, routeId); } /* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ function cell( tag: "th" | "td", text: string, span?: { col?: number; row?: number }, key?: string | null, ): HTMLElement { const element = document.createElement(tag); element.textContent = text; if (span?.col) element.setAttribute("colspan", String(span.col)); if (span?.row) element.setAttribute("rowspan", String(span.row)); // 그림 짝이 있는 칸만 누름(150-4 · `_Cross_Detail_Pick`) if (key && DETAIL_TARGETS[key]) element.dataset.key = key; return element; } /** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement { const { layout, text } = sheet; const value = (key: string | null): string => (key ? (text[key] ?? "-") : ""); const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); titleRow.append(cell("th", title, { col: 8 })); const headRow = table.insertRow(); for (const [label, key] of layout.header) headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key)); layout.left.forEach(([groupL, subL, keyL], index) => { const row = table.insertRow(); // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL)); else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL)); if (subL !== null) row.append(cell("th", subL, undefined, keyL)); row.append(cell("td", value(keyL), undefined, keyL)); const [groupM, subM, keyM] = layout.middle[index]; if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM)); row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM)); const [labelR, keyR] = layout.right[index]; row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR)); }); return table; } /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */ export function attachDetailZoom( clone: SVGSVGElement, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, ): ZoomPanHandle { const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); layer.append(...clone.childNodes); // 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확 clone.append(layer); return attachSvgZoomPan({ svg: clone, layer, widthPx: width, heightPx: height, pad: CROSS_PAD, maxScale: 16, // 1배에서도 팬 — 그림 둘레 한 장씩 열어 둠 content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, onChange, }); } /* ── 모달 ───────────────────────────────────────────────────────────── */ interface OpenDetail { stationId: string; section: CrossSection; paint: () => void; loadTable: () => void; close: () => void; stop: () => void; } let current: OpenDetail | null = null; let tableTimer = 0; /** 모달 그림을 그리는 동안 — 카드 그리기가 부르는 `refreshCrossDetail` 이 다시 그리기를 걸지 않게. */ let drawing = false; /** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null { drawing = true; try { const svg = host?.chart(section.station_id, widthPx, heightPx); if (!svg) return null; const clone = svg.cloneNode(true) as SVGSVGElement; clone.querySelectorAll("clipPath[id]").forEach((clip) => { const id = clip.id; clip.id = `b06-detail-${id}`; clone .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); return clone; } finally { drawing = false; } } function titleOf(section: CrossSection): string { const stations = host?.detail()?.longitudinal.stations ?? []; return stationLabel(section.chainage_m, inferStationInterval(stations)); } /** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */ export function openCrossDetail(section: CrossSection): void { current?.close(); const title = titleOf(section); let tableSeq = 0; openModal({ title: `${title} 상세`, closeLabel: "닫기", dialogClass: "b06-detail", wide: true, mount(body, close) { const note = document.createElement("textarea"); note.className = "ui-input b06-detail__note"; note.rows = 3; note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘"; note.value = noteOf(section); note.addEventListener("input", () => writeNote(state.section, note.value)); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); tableBox.className = "b06-detail__table"; body.append(cad, note, tableBox); // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) let picked: string | null = null; const showPicked = (): void => { const svg = cad.querySelector("svg"); const present = svg ? presentKeys(svg, state.section.design) : new Set(); if (picked && !present.has(picked)) picked = null; tableBox.querySelectorAll("[data-key]").forEach((el) => { el.classList.toggle("is-picked", el.dataset.key === picked); el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? "")); }); if (svg) highlightDetail(svg, state.section.design, picked); }; tableBox.addEventListener("click", (event) => { const el = (event.target as HTMLElement).closest("[data-key]"); const key = el?.dataset.key; if (!key || el.hasAttribute("data-empty")) return; picked = picked === key ? null : key; showPicked(); }); // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) let zoom: ZoomPanState | undefined; let drawnSize = ""; const resize = new ResizeObserver(() => state.paint()); resize.observe(cad); const state: OpenDetail = { stationId: section.station_id, section, paint() { const { clientWidth: width, clientHeight: height } = cad; if (width < 1 || height < 1) return; const size = `${width}x${height}`; if (size !== drawnSize) zoom = undefined; drawnSize = size; const clone = drawChart(state.section, width, height); if (!clone) cad.replaceChildren(document.createTextNode("그림 없음")); else { const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); cad.replaceChildren(clone, buildZoomControls(handle)); } showPicked(); }, loadTable() { const projectId = host?.projectId; const routeId = host?.routeId(); if (!projectId || routeId === null || routeId === undefined) { tableBox.textContent = "노선 정보 없음"; return; } const seq = ++tableSeq; tableBox.classList.add("is-loading"); fetchCrossQuantityTable( projectId, routeId, state.section.chainage_m, state.section.design, ) .then((sheet) => { if (seq !== tableSeq || current !== state) return; tableBox.replaceChildren(buildQuantityTable(sheet, title)); showPicked(); }) .catch((error: unknown) => { if (seq !== tableSeq || current !== state) return; tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`; }) .finally(() => seq === tableSeq && tableBox.classList.remove("is-loading")); }, close() { forget(); close(); }, stop: () => resize.disconnect(), }; current = state; tableBox.textContent = "수량표 불러오는 중…"; state.paint(); state.loadTable(); }, // 닫기 단추 · Esc 도 같은 정리를 거침 beforeClose: () => { forget(); return true; }, }); } /** 열린 모달 기억을 지움(닫힐 때). */ function forget(): void { window.clearTimeout(tableTimer); current?.stop(); current = null; } /** * 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로 * 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만). */ export function refreshCrossDetail(section: CrossSection): void { const open = current; if (drawing || !open || open.stationId !== section.station_id) return; open.section = section; requestAnimationFrame(() => current === open && open.paint()); window.clearTimeout(tableTimer); tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400); }