/* ============================================================================= * B05_Profile_UI_Profile_CrossModal.ts * 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21). * * 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침 * (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산 * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 * 측점일 때만 · [닫기]. * ========================================================================== */ import "./B05_Profile_UI_Profile_CrossModal.css"; import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; import { draftProjectId } from "../A00_Common/design/design_store"; import { chartYAtElevation, parseWindowTransform, type ChartWindow, } from "../A00_Common/design/logic/design_profile_free"; import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal"; import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch"; import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe"; import { stationLabel } from "../common_util/common_util_station"; import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { buildCrossSvg, CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; /** 원지반선을 눌렀다고 볼 위아래 여유(px). */ const GROUND_SLACK_PX = 8; /** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */ const SAME_M = 0.5; export interface CrossModalOpen { chainageM: number; /** 지금 종단 측점 누가거리(일반 · 구조물 · 추가) — 같은 m 막음. */ stations: readonly number[]; intervalM: number; } /** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */ export function groundPickAt( chart: HTMLElement, event: MouseEvent, chainageAtPx: (px: number) => number, samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>, ): number | null { const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); if (!svg) return null; const read = (name: string): number => Number(svg.getAttribute(name)); const view: ChartWindow = { center: read(Y_WINDOW_ATTRS.center), pxPerM: read(Y_WINDOW_ATTRS.pxPerM), zero: read(Y_WINDOW_ATTRS.zero), ...parseWindowTransform( svg.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null, ), }; if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) return null; const rect = chart.getBoundingClientRect(); const chainage = chainageAtPx(event.clientX - rect.left); const valid = samples.filter( (s): s is { chainage_m: number; elevation_m: number } => Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN), ); const after = valid.findIndex((s) => s.chainage_m >= chainage); if (!Number.isFinite(chainage) || after <= 0) return null; const [a, b] = [valid[after - 1], valid[after]]; const t = (chainage - a.chainage_m) / (b.chainage_m - a.chainage_m || 1); const ground = a.elevation_m + (b.elevation_m - a.elevation_m) * t; const top = svg.getBoundingClientRect().top - rect.top; const groundY = chartYAtElevation(view, ground) + top; return Math.abs(event.clientY - rect.top - groundY) <= GROUND_SLACK_PX ? Math.round(chainage) : null; } interface CrossModal { open(request: CrossModalOpen): void; } const modals = new WeakMap(); function createCrossModal(host: HTMLElement): CrossModal { const root = document.createElement("section"); root.className = "b05-crossmodal"; root.setAttribute("role", "dialog"); root.hidden = true; root.innerHTML = `
임시 횡단
`; const $ = (selector: string): T => root.querySelector(selector)!; const station = $(".b05-crossmodal__station"); const box = $(".b05-crossmodal__box"); const foot = $(".b05-crossmodal__foot"); const add = $('[data-act="add"]'); const remove = $('[data-act="remove"]'); // 종단 그래프 누름 · 우클릭으로 새지 않게(창 안 조작은 창 몫) ["click", "pointerdown", "contextmenu"].forEach((type) => root.addEventListener(type, (event) => event.stopPropagation()), ); host.append(root); let current: CrossModalOpen | null = null; let turn = 0; const projectId = (): string => draftProjectId(); const isPlus = (m: number): boolean => plusStationsOf(projectId()).includes(m); function syncButtons(): void { if (!current) return; const m = current.chainageM; const plus = isPlus(m); const taken = !plus && current.stations.some((at) => Math.abs(at - m) < SAME_M); add.hidden = plus; add.disabled = taken; add.title = taken ? "이미 측점이 있는 자리입니다." : ""; remove.hidden = !plus; } async function draw(m: number): Promise { const mine = (turn += 1); box.replaceChildren(); foot.textContent = "횡단을 뜨는 중…"; try { // 지금 계획선(초안 포함 · 묶음 같은 계산) — 격자 없음 · 분석 지형 다름이면 서버(저장 계획선) const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch( () => null, ); const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]); if (mine !== turn) return; if (!cross) { foot.textContent = "이 자리의 횡단을 뜨지 못했습니다."; return; } const preview = cross as unknown as CrossPreviewResponse; const rect = box.getBoundingClientRect(); const plot = buildCrossSvg( preview, Math.round(rect.width) || 320, Math.round(rect.height) || 180, ); box.replaceChildren(plot.svg); attachSvgZoomPan({ svg: plot.svg, layer: plot.layer, widthPx: plot.width, heightPx: plot.height, pad: CROSS_PLOT_PAD, content: plot.content, wheelZoom: true, }); const planZ = Number( (preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m, ); root.dataset.planZ = Number.isFinite(planZ) ? planZ.toFixed(3) : ""; foot.textContent = [ Number.isFinite(planZ) ? `계획고 ${planZ.toFixed(2)} m` : "", summarizeCross(preview), now ? "" : "저장 계획선 기준", ] .filter(Boolean) .join(" · "); } catch (error) { if (mine === turn) foot.textContent = error instanceof Error ? error.message : "횡단을 뜨지 못했습니다."; } } // 계획고 · 노선이 바뀌면(묶음 cross 차례) 떠 있는 창을 다시 그림 let watched: unknown = null; let stopPipe = (): void => undefined; function watchPipe(): void { const pipe = pagePipeline(projectId()); if (pipe === watched) return; stopPipe(); watched = pipe; stopPipe = pipe?.subscribe((event) => { if (event.kind === "stage" && event.stage === "cross" && current) void draw(current.chainageM); }, "종단 작은 창") ?? ((): void => undefined); } function close(): void { root.hidden = true; current = null; turn += 1; } add.addEventListener("click", () => { if (!current || add.disabled) return; writePlusDraft(projectId(), [...plusStationsOf(projectId()), current.chainageM]); syncButtons(); }); remove.addEventListener("click", () => { if (!current) return; const m = current.chainageM; writePlusDraft( projectId(), plusStationsOf(projectId()).filter((at) => at !== m), ); syncButtons(); }); $('[data-act="close"]').addEventListener("click", close); return { open(request) { current = request; station.textContent = stationLabel(request.chainageM, request.intervalM); root.hidden = false; syncButtons(); watchPipe(); void draw(request.chainageM); }, }; } /** 종단 칸(`host` · 다시 그려도 남는 칸)에 작은 창을 열고 그 자리 임시 횡단을 그린다. */ export function openCrossModal(host: HTMLElement, request: CrossModalOpen): void { let modal = modals.get(host); if (!modal) modals.set(host, (modal = createCrossModal(host))); modal.open(request); } /** 추가 측점 지움 — 우클릭 「측점 삭제」(368-3). */ export function removePlusStation(chainageM: number): void { const projectId = draftProjectId(); writePlusDraft( projectId, plusStationsOf(projectId).filter((at) => at !== Math.round(chainageM)), ); }