summaryrefslogtreecommitdiff
path: root/web/frontend/ride-detail.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:23:32 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:23:32 +0200
commit53ab332e6d2afe9818914e0026310e4cadcc4202 (patch)
tree198d708fa05d4247bdcc0cf2900fdf551d61b7d6 /web/frontend/ride-detail.ts
parent2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff)
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/frontend/ride-detail.ts')
-rw-r--r--web/frontend/ride-detail.ts142
1 files changed, 142 insertions, 0 deletions
diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts
new file mode 100644
index 0000000..fb75c19
--- /dev/null
+++ b/web/frontend/ride-detail.ts
@@ -0,0 +1,142 @@
+import { OfficialClimbProfileController } from "./official-climb-profile.js";
+import { RideBoundaryController } from "./ride-detail-boundaries.js";
+import { RideProfileCanvas } from "./ride-detail-canvas.js";
+import { RideDetailMap } from "./ride-detail-map.js";
+import { formatDistance, formatElevation } from "./ride-detail-logic.js";
+import type { ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint, RideRoute } from "./types.js";
+
+export const mountRideDetail = (): void => {
+ const mapElement = document.getElementById("ride-map");
+ const routeElement = document.getElementById("ride-route");
+ const profileElement = document.getElementById("ride-profile");
+ const canvas = document.getElementById("ride-profile-chart");
+ const hoverOutput = document.getElementById("ride-profile-hover");
+ if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
+ const routeScript = routeElement as HTMLScriptElement;
+ const profileScript = profileElement as HTMLScriptElement;
+ const profileCanvasElement = canvas as HTMLCanvasElement;
+ const leaflet = window.L;
+ if (!leaflet) return;
+
+ const colorProbe = document.createElement("span");
+ colorProbe.hidden = true;
+ document.body.append(colorProbe);
+ const resolveColor = (name: string): string => {
+ colorProbe.style.color = `var(${name})`;
+ return getComputedStyle(colorProbe).color;
+ };
+ const colors: RideDetailColors = {
+ accent: resolveColor("--color-accent"),
+ forest: resolveColor("--color-forest"),
+ subtle: resolveColor("--color-subtle"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"),
+ grid: resolveColor("--color-plot-grid"),
+ accentFill: resolveColor("--color-accent-fill"),
+ climbLabel: resolveColor("--color-climb-label"),
+ crossing: resolveColor("--color-crossing"),
+ crossingLabel: resolveColor("--color-crossing-label"),
+ hoverLine: resolveColor("--color-hover-line"),
+ climbRoute: resolveColor("--color-climb-route"),
+ climbFocusFill: resolveColor("--color-climb-focus-fill"),
+ };
+ colorProbe.remove();
+
+ const route = JSON.parse(routeScript.textContent ?? "") as RideRoute;
+ const profile = JSON.parse(profileScript.textContent ?? "") as RideProfile;
+ const points: RideProfilePoint[] = profile.points;
+ if (points.length === 0) return;
+
+ const climbItems = [...document.querySelectorAll<HTMLElement>("[data-climb-item]")];
+ const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex ?? "", 10));
+ const previousClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-previous]");
+ const nextClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-next]");
+ const climbPosition = document.querySelector<HTMLElement>("[data-climb-position]");
+ const climbBounds: ClimbBounds[] = profile.climbs.map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex }));
+ const state = {
+ focusedClimbItemIndex: 0,
+ focusedClimbIndex: climbItemIndices[0] ?? 0,
+ };
+
+ const mapController = new RideDetailMap({
+ leaflet,
+ element: mapElement,
+ route,
+ points,
+ climbs: climbBounds,
+ colors,
+ });
+ const officialProfileController = new OfficialClimbProfileController(points);
+ let boundaryController: RideBoundaryController | undefined;
+ const profileCanvas = new RideProfileCanvas({
+ canvas: profileCanvasElement,
+ points,
+ profile,
+ colors,
+ getClimbBounds: () => climbBounds,
+ canSelectPoint: (source) => boundaryController?.isSelecting(source) ?? false,
+ onPointSelected: (index) => boundaryController?.choosePoint(index),
+ onPointHover: (index) => {
+ if (index < 0) {
+ mapController.clearPoint();
+ hoverOutput.textContent = "Hover or focus the profile to inspect elevation.";
+ return;
+ }
+ const point = points[index];
+ mapController.showPoint(index);
+ hoverOutput.textContent = `${formatDistance(point.distanceKm)} ยท ${formatElevation(point.elevationM)}`;
+ },
+ });
+
+ const updateClimbLayer = (index: number): void => {
+ mapController.updateClimbLayer(index, climbBounds[index], index === state.focusedClimbIndex);
+ };
+ boundaryController = new RideBoundaryController({
+ forms: [...document.querySelectorAll<HTMLFormElement>("[data-official-climb-form]")],
+ points,
+ climbBounds,
+ onBoundaryChanged: (index) => {
+ updateClimbLayer(index);
+ profileCanvas.redraw();
+ },
+ onPointSelected: (index) => profileCanvas.showPoint(index),
+ setMessage: (message) => {
+ hoverOutput.textContent = message;
+ },
+ });
+
+ mapController.onClick((event) => {
+ if (!boundaryController?.isSelecting("map")) return;
+ boundaryController.choosePoint(mapController.nearestPointIndex(event.latlng.lat, event.latlng.lng));
+ });
+
+ const updateClimbFocus = (index: number, zoom: boolean): void => {
+ if (climbItems.length === 0) return;
+ state.focusedClimbItemIndex = Math.max(0, Math.min(climbItems.length - 1, index));
+ state.focusedClimbIndex = climbItemIndices[state.focusedClimbItemIndex];
+ for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
+ const active = itemIndex === state.focusedClimbItemIndex;
+ climbItems[itemIndex].hidden = !active;
+ climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
+ climbItems[itemIndex].classList.toggle("focused", active);
+ updateClimbLayer(climbItemIndices[itemIndex]);
+ }
+ if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbItemIndex + 1} of ${climbItems.length}`;
+ if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbItemIndex === 0;
+ if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbItemIndex === climbItems.length - 1;
+ boundaryController?.clearSelection();
+ profileCanvas.setFocusedClimbIndex(state.focusedClimbIndex);
+ if (zoom) mapController.zoomToClimb(climbBounds[state.focusedClimbIndex]);
+ };
+
+ if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex - 1, true));
+ if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex + 1, true));
+ window.addEventListener("resize", () => {
+ profileCanvas.redraw();
+ officialProfileController.redrawOpen();
+ });
+ if (climbItems.length > 0) updateClimbFocus(0, false);
+ else profileCanvas.redraw();
+};
+
+mountRideDetail();