Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts
T
2026-07-18 23:59:28 +09:00

482 lines
17 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 = 260;
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;
}
export function createLongitudinalProfile(
data: LongitudinalSection,
selectedStationId: string | null,
verticalExaggeration: number,
yScaleOptions: YScaleOptions | undefined,
onSelectStation: (stationId: string) => void,
): 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",
viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`,
role: "img",
"aria-label": L("B06_Profile_View_Longitudinal"),
});
svg.append(
svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, 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 = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right;
const plotHeight = LONG_HEIGHT - 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;
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: LONG_WIDTH - 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": `${station.label} ${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: LONG_HEIGHT - LONG_PAD.bottom + 8,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
svgText(station.label, {
x: stationX,
y: LONG_HEIGHT - 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: LONG_HEIGHT - LONG_PAD.bottom,
x2: LONG_WIDTH - LONG_PAD.right,
y2: LONG_HEIGHT - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
x2: LONG_PAD.left,
y2: LONG_HEIGHT - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
x: LONG_WIDTH / 2,
y: LONG_HEIGHT - 4,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 15,
y: LONG_HEIGHT / 2,
"text-anchor": "middle",
transform: `rotate(-90 15 ${LONG_HEIGHT / 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,
): 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 = section.label;
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 valid = section.samples.filter(validElevation);
if (!valid.length) {
card.append(emptyView(L("B06_Profile_View_NoCross")));
} else {
const offsets = section.samples.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 = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right;
const plotHeight = CROSS_HEIGHT - 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",
viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`,
role: "img",
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
});
svg.append(
svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, 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: CROSS_HEIGHT - CROSS_PAD.bottom,
class: "b06-chart__grid",
}),
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
x: x(tick),
y: CROSS_HEIGHT - 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: CROSS_WIDTH - 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 section.samples) {
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)
: CROSS_HEIGHT / 2;
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
y1: CROSS_HEIGHT - CROSS_PAD.bottom,
x2: CROSS_WIDTH - CROSS_PAD.right,
y2: CROSS_HEIGHT - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: CROSS_PAD.left,
y1: CROSS_PAD.top,
x2: CROSS_PAD.left,
y2: CROSS_HEIGHT - 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: CROSS_WIDTH / 2,
y: CROSS_HEIGHT - 8,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 13,
y: CROSS_HEIGHT / 2,
"text-anchor": "middle",
transform: `rotate(-90 13 ${CROSS_HEIGHT / 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) => void;
clear: () => 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;
const draw = (): void => {
root.replaceChildren();
if (!currentDetail) return;
const detail = currentDetail;
const yScale = calculateYScale(detail);
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 longitudinalHeader = document.createElement("header");
const longitudinalTitle = document.createElement("h3");
longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal");
const stationCount = document.createElement("span");
stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`;
longitudinalHeader.append(longitudinalTitle, stationCount);
longitudinalPanel.append(
longitudinalHeader,
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
yScale,
(stationId) => selectStation(stationId, true),
),
);
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";
if (detail.cross_sections.length) {
detail.cross_sections.forEach((section) =>
grid.append(
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
yScale,
(stationId) => selectStation(stationId, false),
),
),
);
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
root.append(longitudinalPanel, crossHeading, grid);
};
return {
root,
render(detail, verticalExaggeration) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
draw();
},
clear() {
currentDetail = null;
selectedStationId = null;
root.replaceChildren();
},
};
}