594 lines
20 KiB
TypeScript
594 lines
20 KiB
TypeScript
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<K extends keyof SVGElementTagNameMap>(
|
|
tag: K,
|
|
attributes: Record<string, string | number> = {},
|
|
): 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<string, string | number>): 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<number, number>();
|
|
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();
|
|
},
|
|
};
|
|
}
|