/* ============================================================================= * A00_Common/design/design_profile_free_view.ts * 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1). * * 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔 * 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린 * 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다. * * 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다. * 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게). * ========================================================================== */ import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal"; import { chartYAtElevation, elevationAtChartY, parseWindowTransform, type ChartWindow, type FreeDraw, } from "./logic/design_profile_free"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */ export interface FreeChartGeom { /** 그래프 래퍼(`b05-profile__chart`). */ chart: HTMLElement; /** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */ scroller: HTMLElement; x: (chainageM: number) => number; toChainage: (px: number) => number; widthPx: number; heightPx: number; /** 그래프 세로 측점선 chainage(정규 + 구조물). */ stations: number[]; } export interface FreeView { /** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */ mount: (geom: FreeChartGeom | null, active: boolean) => void; redraw: () => void; } function svgEl(tag: string, attrs: Record): SVGElement { const element = document.createElementNS(SVG_NS, tag); Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value))); return element; } /** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */ function readWindow(chart: HTMLElement): { view: ChartWindow; top: 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 layer = svg.querySelector(`.${Y_WINDOW_CLASS}`); const view: ChartWindow = { center: read(Y_WINDOW_ATTRS.center), pxPerM: read(Y_WINDOW_ATTRS.pxPerM), zero: read(Y_WINDOW_ATTRS.zero), ...parseWindowTransform(layer?.getAttribute("transform") ?? null), }; if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) { return null; } // 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다. const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top; return { view, top }; } export function createFreeView(free: FreeDraw): FreeView { let geom: FreeChartGeom | null = null; let active = false; let overlay: SVGSVGElement | null = null; let listeners: AbortController | null = null; /** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */ function pointerAt(event: MouseEvent) { if (!geom) return null; const win = readWindow(geom.chart); if (!win) return null; const rect = geom.chart.getBoundingClientRect(); const chainageM = geom.toChainage(event.clientX - rect.left); if (!Number.isFinite(chainageM)) return null; const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top); return { chainageM, elevationM, stations: geom.stations }; } function redraw(): void { overlay?.remove(); overlay = null; if (!geom || !active) return; const win = readWindow(geom.chart); if (!win) return; const { x, widthPx, heightPx } = geom; const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top; const marks = free.marks(); const svg = svgEl("svg", { class: "b05-profile-freemark", width: widthPx, height: heightPx, viewBox: `0 0 ${widthPx} ${heightPx}`, }) as SVGSVGElement; if (marks.band) { const [left, right] = marks.band.map(x); svg.append( svgEl("rect", { class: "b05-profile-freemark__band", x: left, y: 0, width: Math.max(0, right - left), height: heightPx, }), ); } marks.edges.forEach((chainage) => svg.append( svgEl("line", { class: "b05-profile-freemark__edge", x1: x(chainage), x2: x(chainage), y1: 0, y2: heightPx, }), ), ); marks.segments.forEach((segment) => { const x1 = x(segment.from.chainage_m); const x2 = x(segment.to.chainage_m); const y1 = y(segment.from.elevation_m); const y2 = y(segment.to.elevation_m); const over = segment.over ? " is-over" : ""; svg.append( svgEl("line", { class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`, x1, y1, x2, y2, }), ); const label = svgEl("text", { class: `b05-profile-freemark__grade${over}`, x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 6, "text-anchor": "middle", }); label.textContent = `${segment.grade_pct.toFixed(1)}%`; svg.append(label); }); marks.dots.forEach((dot) => svg.append( svgEl("circle", { class: "b05-profile-freemark__dot", cx: x(dot.chainage_m), cy: y(dot.elevation_m), r: 3.5, }), ), ); overlay = svg; geom.chart.append(svg); } function mount(next: FreeChartGeom | null, on: boolean): void { listeners?.abort(); listeners = null; if (next) geom = next; active = on; if (!on) free.reset(); if (!geom || !on || !geom.chart.isConnected) { overlay?.remove(); overlay = null; return; } listeners = new AbortController(); const { signal } = listeners; const chart = geom.chart; const swallow = (event: Event): void => { event.stopPropagation(); event.stopImmediatePropagation(); }; // 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게. chart.addEventListener("pointerdown", swallow, { capture: true, signal }); chart.addEventListener( "click", (event) => { swallow(event); event.preventDefault(); const pointer = pointerAt(event); if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations); free.hover(pointerAt(event)); redraw(); }, { capture: true, signal }, ); chart.addEventListener( "mousemove", (event) => { free.hover(pointerAt(event)); redraw(); }, { signal }, ); chart.addEventListener( "mouseleave", () => { free.hover(null); redraw(); }, { signal }, ); geom.scroller.addEventListener("scroll", redraw, { signal, passive: true }); redraw(); } return { mount, redraw }; }