/* ============================================================================= * B05_Profile_UI_Profile_CrossModal.ts * 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21). * * 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침 * (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산 * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 * 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1). * ========================================================================== */ 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"; import { crossPickAt, groundElevationAt, type CrossPickHit, type GroundSample, } from "./logic/B05_Profile_Profile_CrossPick"; /** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */ const SAME_M = 0.5; export interface CrossModalOpen { chainageM: number; /** 지금 종단 측점 누가거리(일반 · 구조물 · 추가) — 같은 m 막음. */ stations: readonly number[]; intervalM: number; } /** 그래프 위 포인터 → 임시 횡단 자리(정수 m · 그 자리 원지반선 y px) · 아니면 null. * 누름(작은 창) · 가까이 표시가 같이 씀(490-1) — 판정은 `crossPickAt` 한 벌. */ export function crossPickFromEvent( chart: HTMLElement, event: MouseEvent, chainageAtPx: (px: number) => number, samples: readonly GroundSample[], ): { chainageM: number; groundY: number | null } | null { const target = event.target as Element; const marker = target.closest(".b06-chart__station"); const plusRaw = marker?.querySelector(".b06-chart__station-line--plus") ? Number(marker.getAttribute("data-chainage")) : null; const hit: CrossPickHit = plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null; const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); 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, ), }; const rect = chart.getBoundingClientRect(); const top = svg ? svg.getBoundingClientRect().top - rect.top : 0; const viewOk = !!svg && [view.center, view.pxPerM, view.zero].every(Number.isFinite) && view.pxPerM > 0; const groundY = (elevation: number): number => viewOk ? chartYAtElevation(view, elevation) + top : NaN; // 포인터 이동은 소수 px · 누름은 정수 px — 내려 맞춰 같은 자리 = 같은 m(0.5 m 경계에서 1 m 어긋남 막음). const chainageM = crossPickAt({ hit, chainageM: chainageAtPx(Math.floor(event.clientX) - rect.left), pointerY: Math.floor(event.clientY) - rect.top, samples, groundY, }); if (chainageM === null || !Number.isFinite(chainageM)) return null; const ground = groundElevationAt(samples, chainageM); const y = ground === null ? NaN : groundY(ground); return { chainageM, groundY: Number.isFinite(y) ? y : null }; } /** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1). * 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */ export function mountCrossHint( chart: HTMLElement, scroller: HTMLElement, options: { pick: (event: MouseEvent) => { chainageM: number; groundY: number | null } | null; x: (chainageM: number) => number; intervalM: number; blocked: () => boolean; }, ): void { const hint = document.createElement("div"); hint.className = "b05-crosshint"; hint.hidden = true; hint.innerHTML = ``; const dot = hint.querySelector(".b05-crosshint__dot")!; const label = hint.querySelector(".b05-crosshint__label")!; chart.append(hint); const hide = (): void => { hint.hidden = true; chart.style.cursor = ""; }; chart.addEventListener("pointermove", (event) => { const at = event.buttons || options.blocked() ? null : options.pick(event); if (!at) return hide(); hint.hidden = false; hint.style.left = `${options.x(at.chainageM)}px`; dot.hidden = at.groundY === null; // 원 · 글 높이 = 원지반선(없으면 위쪽) — 글이 위 ▲ 단추를 가리지 않게 원 옆에 둠. hint.style.setProperty("--crosshint-y", `${at.groundY ?? 12}px`); label.textContent = `${stationLabel(at.chainageM, options.intervalM)} · 눌러 임시 횡단`; chart.style.cursor = "pointer"; scroller.addEventListener("scroll", hide, { once: true, passive: true }); }); chart.addEventListener("pointerleave", hide); } 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)), ); }