import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossSection, LongitudinalSection, SectionDetailResponse, SectionSample, } from "./B06_wf3_ProfileCross_Api_Fetch"; const SVG_NS = "http://www.w3.org/2000/svg"; const LONG_WIDTH = 1200; const LONG_HEIGHT = 220; const CROSS_WIDTH = 560; const CROSS_HEIGHT = 250; const CROSS_GRID_MIN_WIDTH = 480; const CROSS_GRID_GAP = 16; const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; interface YScaleOptions { pixelsPerMeter: number; globalMinElevation: number; globalMaxElevation: number; } function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function svgElement( tag: K, attributes: Record = {}, ): SVGElementTagNameMap[K] { const element = document.createElementNS(SVG_NS, tag); Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); return element; } function svgText(value: string, attributes: Record): SVGTextElement { const text = svgElement("text", attributes); text.textContent = value; return text; } function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } { return ( sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) ); } function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { const elevations = [ ...detail.longitudinal.samples.map((sample) => sample.elevation_m), ...detail.cross_sections.flatMap((section) => section.samples.map((sample) => sample.elevation_m), ), ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); if (!elevations.length) return undefined; const globalMinElevation = Math.min(...elevations); const globalMaxElevation = Math.max(...elevations); const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; return { pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), globalMinElevation, globalMaxElevation, }; } function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; empty.textContent = message; return empty; } function inferStationInterval(stations: Array<{ chainage_m: number }>): number { const counts = new Map(); for (let index = 1; index < stations.length; index += 1) { const difference = stations[index].chainage_m - stations[index - 1].chainage_m; if (difference <= 0) continue; const rounded = Math.round(difference * 10) / 10; counts.set(rounded, (counts.get(rounded) ?? 0) + 1); } return ( [...counts.entries()].sort( ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, )[0]?.[0] ?? 1 ); } function stationLabel(chainage: number, interval: number): string { const safeInterval = interval > 0 ? interval : 1; let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); let remainder = chainage - stationNumber * safeInterval; if (Math.abs(remainder) < 0.05) remainder = 0; if (remainder >= safeInterval - 0.05) { stationNumber += 1; remainder = 0; } return `${stationNumber}+${remainder.toFixed(1)}`; } export function longitudinalMinimumWidth( data: LongitudinalSection, configuredStationInterval?: number, ): number { const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); const longestLabelLength = Math.max( 1, ...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length), ); const labelWidth = Math.max(48, longestLabelLength * 6 + 16); return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; } export function createLongitudinalProfile( data: LongitudinalSection, selectedStationId: string | null, verticalExaggeration: number, yScaleOptions: YScaleOptions | undefined, onSelectStation: (stationId: string) => void, configuredStationInterval?: number, widthPx = LONG_WIDTH, heightPx = LONG_HEIGHT, minimumWidthPx = widthPx, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); const wrapper = document.createElement("div"); wrapper.className = "b06-section__chart-wrap"; const svg = svgElement("svg", { class: "b06-section__chart", width: widthPx, height: heightPx, viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": L("B06_Profile_View_Longitudinal"), }); svg.style.width = "100%"; svg.style.minWidth = `${minimumWidthPx}px`; svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); const elevations = samples.map((sample) => sample.elevation_m); const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; const elevationSpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max(rawMax - rawMin, 1); const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; const y = (elevation: number) => LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { const gridY = LONG_PAD.top + ratio * plotHeight; const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; svg.append( svgElement("line", { x1: LONG_PAD.left, y1: gridY, x2: widthPx - LONG_PAD.right, y2: gridY, class: "b06-chart__grid", }), svgText(`${rawValue.toFixed(1)}m`, { x: LONG_PAD.left - 9, y: gridY + 4, "text-anchor": "end", class: "b06-chart__tick", }), ); } for (const station of data.stations) { const stationX = x(station.chainage_m); const selected = station.station_id === selectedStationId; const marker = svgElement("g", { class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, tabindex: "0", role: "button", "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, }); marker.addEventListener("click", () => onSelectStation(station.station_id)); marker.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); }); marker.append( svgElement("line", { x1: stationX, y1: LONG_PAD.top, x2: stationX, y2: heightPx - LONG_PAD.bottom + 8, class: "b06-chart__station-hit", }), svgElement("line", { x1: stationX, y1: LONG_PAD.top, x2: stationX, y2: heightPx - LONG_PAD.bottom + 8, class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, }), svgText(stationLabel(station.chainage_m, stationInterval), { x: stationX, y: heightPx - 23, "text-anchor": "middle", class: "b06-chart__station-label", }), ); svg.append(marker); } const points = samples .map((sample) => { const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; }) .join(" "); svg.append( svgElement("polyline", { points, class: "b06-chart__profile" }), svgElement("line", { x1: LONG_PAD.left, y1: heightPx - LONG_PAD.bottom, x2: widthPx - LONG_PAD.right, y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: LONG_PAD.left, y1: LONG_PAD.top, x2: LONG_PAD.left, y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgText(L("B06_Profile_View_LongitudinalXAxis"), { x: widthPx / 2, y: heightPx - 4, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 15, y: heightPx / 2, "text-anchor": "middle", transform: `rotate(-90 15 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); wrapper.append(svg); return wrapper; } export function createCrossSectionCard( section: CrossSection, selected: boolean, verticalExaggeration: number, yScaleOptions: YScaleOptions | undefined, onSelect: (stationId: string) => void, stationInterval: number, crossHalfWidth?: number, widthPx = CROSS_WIDTH, heightPx = CROSS_HEIGHT, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; card.tabIndex = 0; card.addEventListener("click", () => onSelect(section.station_id)); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); }); const header = document.createElement("header"); const title = document.createElement("div"); const label = document.createElement("strong"); label.textContent = stationLabel(section.chainage_m, stationInterval); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); const kind = document.createElement("span"); kind.textContent = section.kind === "ep" ? L("B06_Profile_View_Kind_EP") : section.kind === "bp" ? L("B06_Profile_View_Kind_BP") : L("B06_Profile_View_Kind_Station"); header.append(title, kind); card.append(header); const sourceSamples = section.samples.filter( (sample) => crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, ); const valid = sourceSamples.filter(validElevation); if (!valid.length) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); const minOffset = Math.min(...offsets, -1); const maxOffset = Math.max(...offsets, 1); const elevations = valid.map((sample) => sample.elevation_m); const rawMin = Math.min(...elevations); const rawMax = Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const exaggeration = Math.max(verticalExaggeration, 0.1); const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; const plotHeight = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; const displaySpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); const displayMin = elevationMid - displaySpan / 2; const displayMax = elevationMid + displaySpan / 2; const x = (offset: number) => CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth; const y = (elevation: number) => CROSS_PAD.top + ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; const svg = svgElement("svg", { class: "b06-section__chart", width: widthPx, height: heightPx, viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, }); svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const xTicks = Array.from( { length: 7 }, (_, index) => minOffset + ((maxOffset - minOffset) * index) / 6, ); for (const tick of xTicks) { svg.append( svgElement("line", { x1: x(tick), y1: CROSS_PAD.top, x2: x(tick), y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__grid", }), svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { x: x(tick), y: heightPx - CROSS_PAD.bottom + 16, "text-anchor": "middle", class: "b06-chart__tick", }), ); } const rawDisplaySpan = displaySpan / exaggeration; for (let index = 0; index < 5; index += 1) { const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4; const displayTick = elevationMid + (tick - elevationMid) * exaggeration; svg.append( svgElement("line", { x1: CROSS_PAD.left, y1: y(displayTick), x2: widthPx - CROSS_PAD.right, y2: y(displayTick), class: "b06-chart__grid", }), svgText(tick.toFixed(1), { x: CROSS_PAD.left - 7, y: y(displayTick) + 3, "text-anchor": "end", class: "b06-chart__tick", }), ); } const segments: string[] = []; let current: string[] = []; for (const sample of sourceSamples) { if (!validElevation(sample)) { if (current.length > 1) segments.push(current.join(" ")); current = []; continue; } const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); } if (current.length > 1) segments.push(current.join(" ")); segments.forEach((points) => svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample; return nearest; }, null); const centerX = x(0); const centerY = centerSample ? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration) : heightPx / 2; svg.append( svgElement("line", { x1: CROSS_PAD.left, y1: heightPx - CROSS_PAD.bottom, x2: widthPx - CROSS_PAD.right, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: CROSS_PAD.left, y1: CROSS_PAD.top, x2: CROSS_PAD.left, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: centerX, y1: centerY - 18, x2: centerX, y2: centerY + 18, class: "b06-chart__center-marker", }), svgElement("line", { x1: centerX - 18, y1: centerY, x2: centerX + 18, y2: centerY, class: "b06-chart__center-marker", }), svgText(L("B06_Profile_View_CrossXAxis"), { x: widthPx / 2, y: heightPx - 8, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, y: heightPx / 2, "text-anchor": "middle", transform: `rotate(-90 13 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); card.append(svg); } const footer = document.createElement("footer"); const center = document.createElement("span"); center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; const azimuth = document.createElement("span"); azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`; footer.append(center, azimuth); card.append(footer); return card; } export interface SectionViewController { root: HTMLElement; render: ( detail: SectionDetailResponse, verticalExaggeration: number, crossHalfWidth?: number, stationInterval?: number, ) => void; clear: () => void; dispose: () => void; } export function createSectionView(): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; let renderWidth = 0; let resizeTimer = 0; const contentWidth = (): number => { const style = getComputedStyle(root); return Math.max( 0, root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), ); }; const draw = (): void => { if (!currentDetail || renderWidth <= 0) return; root.replaceChildren(); const detail = currentDetail; const yScale = calculateYScale(detail); const stationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); const selectStation = (stationId: string, scroll: boolean): void => { selectedStationId = stationId; draw(); if (scroll) { document .getElementById(`cross-${stationId}`) ?.scrollIntoView({ behavior: "smooth", block: "center" }); } }; const longitudinalPanel = document.createElement("section"); longitudinalPanel.className = "b06-section__panel"; const longitudinalMinWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); longitudinalPanel.append( createLongitudinalProfile( detail.longitudinal, selectedStationId, currentExaggeration, yScale, (stationId) => selectStation(stationId, true), stationInterval, Math.max(renderWidth, longitudinalMinWidth), LONG_HEIGHT, longitudinalMinWidth, ), ); const crossHeading = document.createElement("div"); crossHeading.className = "b06-section__heading"; const crossTitle = document.createElement("h3"); crossTitle.textContent = L("B06_Profile_View_Cross"); const crossCount = document.createElement("span"); crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`; crossHeading.append(crossTitle, crossCount); const grid = document.createElement("div"); grid.className = "b06-section__grid"; const columnCount = Math.max( 1, Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), ); const cardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; if (detail.cross_sections.length) { detail.cross_sections.forEach((section) => grid.append( createCrossSectionCard( section, section.station_id === selectedStationId, currentExaggeration, yScale, (stationId) => selectStation(stationId, false), stationInterval, currentCrossHalfWidth, cardWidth, CROSS_HEIGHT, ), ), ); } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } root.append(longitudinalPanel, crossHeading, grid); }; const resizeObserver = new ResizeObserver(() => { const nextWidth = contentWidth(); if (nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) return; window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => { renderWidth = nextWidth; draw(); }, 150); }); resizeObserver.observe(root); return { root, render(detail, verticalExaggeration, crossHalfWidth, stationInterval) { currentDetail = detail; currentExaggeration = Math.max(verticalExaggeration, 0.1); currentCrossHalfWidth = crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined; currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; renderWidth = contentWidth(); draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, clear() { currentDetail = null; selectedStationId = null; root.replaceChildren(); }, dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); }, }; }