/* ============================================================================= * B05_Profile_UI_RouteEdit_Cross.ts * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲). * * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다. * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** * 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다. * * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 · * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. * * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * * 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 · * 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름). * 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점). * 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색). * ========================================================================== */ import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; import type { PipelineStation, PlanFootprint, } from "../A00_Common/design/logic/design_pipeline_types"; import { nearestWithin, selectDesign, subscribeSelection, type DesignSelection, } from "../A00_Common/design/design_selection"; import { useDesignStore } from "../A00_Common/design/design_store"; import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { buildCrossSvg, CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; export interface CrossPreviewParams { projectId: string; /** 두 판이 들어앉을 오른쪽 세로 칸. */ side: HTMLElement; /** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */ request: () => { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; station_interval_m: number; station_snap?: boolean; }; /** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */ onShown?: () => void; } /** 고른 측점과 그 좌·우 폭(m · 좌 + 방향 거리) — 평면도가 법선 · 마름모를 그리는 재료. */ export interface CrossWatch { chainageM: number; leftM: number; rightM: number; /** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */ leftCut?: boolean; rightCut?: boolean; /** 구조물 · 관 자리 — 법선 없이 마름모만. */ structure?: true; } /** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ export const PLAN_PICK_FROM = "b05-plan"; /** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */ const isGridStation = (station: PipelineStation): boolean => station.kind === "grid" || station.kind === "end"; export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */ watched: () => CrossWatch | null; } interface CrossPane { root: HTMLElement; /** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */ show: (preview: CrossPreviewResponse | null, intervalM: number) => void; /** 기다리는 중임을 알린다. */ wait: (text: string) => void; } /** * 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다. * * B06 이 [적용]에서 노선 단위 세션 키에 적는다. 이 창은 노선 번호를 몰라 프로젝트 접두어로 * 훑고, 노선이 여럿 남아 있으면 번호가 가장 큰(최신) 것을 쓴다. */ function appliedHalfWidth(projectId: string): number | undefined { const prefix = stateKey("cross-display", projectId, ""); if (!prefix) return undefined; try { const latest = Object.keys(window.sessionStorage) .filter((key) => key.startsWith(prefix)) .sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0]; const value = Number(latest ? readByKey(latest) : NaN); return Number.isFinite(value) && value > 0 ? value : undefined; } catch { return undefined; } } function createPane(title: string, empty: string): CrossPane { const root = document.createElement("section"); root.className = "b05-routeedit__cross"; root.innerHTML = `
${title}
${empty}
`; const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; const box = root.querySelector(".b05-routeedit__cross-box")!; /** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */ let drawn: CrossPreviewResponse | null = null; let size = { width: 0, height: 0 }; let zoom: ZoomPanState | undefined; function draw(preview: CrossPreviewResponse): void { // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. const rect = box.getBoundingClientRect(); size = { width: Math.round(rect.width), height: Math.round(rect.height) }; const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); box.replaceChildren(plot.svg); observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). attachSvgZoomPan({ svg: plot.svg, layer: plot.layer, widthPx: plot.width, heightPx: plot.height, pad: CROSS_PLOT_PAD, content: plot.content, wheelZoom: true, initial: zoom, onChange: (state) => (zoom = state), }); } const observer = new ResizeObserver(() => { if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다. const rect = box.getBoundingClientRect(); const width = Math.round(rect.width); const height = Math.round(rect.height); if (!drawn || width === 0 || (width === size.width && height === size.height)) return; draw(drawn); }); return { root, show(preview, intervalM) { box.replaceChildren(); drawn = preview; zoom = undefined; if (!preview) { station.textContent = ""; foot.textContent = empty; return; } // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. station.textContent = formatStation(preview.chainage_m, intervalM); draw(preview); foot.textContent = summarizeCross(preview); }, wait(text) { box.replaceChildren(); drawn = null; foot.textContent = text; }, }; } /** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */ export function pipelineCrossAt( pipe: DesignPipeline | null, chainageM: number, intervalM: number, halfWidthM: number | undefined, ): CrossPreviewResponse | null { const stations = pipe?.output("ground")?.stations ?? []; if (!pipe || !stations.length) return null; const station = stations.reduce((best, next) => Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best, ); const section = stationSection(station, pipe.output("cross"), {}, intervalM); const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0))); return { status: "success", chainage_m: section.chainage_m, label: section.label ?? null, uphill_side: section.uphill_side ?? null, plan_radius_m: section.plan_radius_m ?? null, curve_widening_m: section.curve_widening_m ?? null, cross_half_width_m: halfWidthM ?? reach, samples: section.samples, design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null, }; } export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다."); const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다."); params.side.append(current.root, previous.root); const interval = (): number => params.request().station_interval_m; /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */ let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; let drawnRoute: unknown = null; const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? []; /** 열쇠로 다시 짚은 지금 측점 — 열쇠가 없으면 들고 있던 누가거리 근처. */ function located(): PipelineStation | null { if (!watching) return null; const list = stationsNow(); const key = watching.key; const at = watching.chainageM; return ( (key ? list.find((station) => station.key === key) : undefined) ?? nearestWithin(list, at, (station) => station.chainageM) ?? null ); } function redraw(): void { if (watching === null || watching.structure) return; const route = pipe?.input("route") ?? null; if (route !== drawnRoute && shown) previous.show(shown, interval()); drawnRoute = route; const at = located()?.chainageM ?? watching.chainageM; shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); if (shown) current.show(shown, interval()); else current.wait("계산 중…"); params.onShown?.(); } // 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음 let pipe: DesignPipeline | null = null; let stopPipe = (): void => undefined; let connected = false; const attach = (): void => { if (current.root.isConnected) connected = true; else if (connected) return stop(); // 편집 창이 닫혔다 const now = pagePipeline(params.projectId); if (now === pipe) return; stopPipe(); pipe = now; stopPipe = now?.subscribe((event) => { if (event.kind === "stage" && event.stage === "cross") redraw(); }, "평면 횡단 판") ?? ((): void => undefined); }; const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach); const stop = (): void => { stopStore(); stopPipe(); stopPick(); }; /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */ function showStation(key: string | null, chainageM: number): void { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. previous.show(null, interval()); watching = { key, chainageM, structure: false }; drawnRoute = pipe?.input("route") ?? null; shown = null; redraw(); const panels = sidePanelsOf.get(params.side); if (panels && panels.current() === null) panels.open("cross"); } /** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */ function follow(selection: DesignSelection): void { attach(); const at = selection.chainageM; if (at === null) { watching = null; shown = null; current.show(null, interval()); previous.show(null, interval()); params.onShown?.(); return; } const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); if (!selection.structureId && (!station || isGridStation(station))) return showStation(station?.key ?? null, station?.chainageM ?? at); watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true }; params.onShown?.(); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { if (from !== PLAN_PICK_FROM) follow(selection); }); attach(); /** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */ const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => { const footprint = useDesignStore(params.projectId, null).get("footprint") as PlanFootprint | null | undefined; const records = (footprint?.records ?? []) as FootprintRecord[]; return ( (key ? records.find((record) => record.key === key) : undefined) ?? nearestWithin(records, chainageM, (record) => record.chainage_m) ); }; return { async open(chainageM) { attach(); const list = stationsNow(); const station = list.length ? list.reduce((best, next) => Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best, ) : null; const at = station?.chainageM ?? chainageM; showStation(station?.key ?? null, at); selectDesign( params.projectId, { chainageM: at, structureId: null }, { from: PLAN_PICK_FROM }, ); }, async refresh() { attach(); redraw(); }, // 자리 = 열쇠로 다시 짚은 측점 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. // 폭 = 그 측점 발자국 비탈 끝 좌 · 우 · 기록이 없으면 「횡단 반폭」(없으면 원지반 선 범위) 양쪽. watched: () => { if (!watching) return null; const station = located(); const chainageM = station?.chainageM ?? watching.chainageM; if (watching.structure || (station && !isGridStation(station))) return { chainageM, leftM: 0, rightM: 0, structure: true }; const record = footprintAt(station?.key ?? watching.key, chainageM); if (record) return { chainageM, leftM: record.left_end_m, rightM: -record.right_end_m, leftCut: record.left_cut, rightCut: record.right_cut, }; if (!shown) return null; return { chainageM, leftM: shown.cross_half_width_m, rightM: shown.cross_half_width_m }; }, }; }