diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
| commit | 53ab332e6d2afe9818914e0026310e4cadcc4202 (patch) | |
| tree | 198d708fa05d4247bdcc0cf2900fdf551d61b7d6 /web/frontend/ride-detail-map.ts | |
| parent | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff) | |
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/frontend/ride-detail-map.ts')
| -rw-r--r-- | web/frontend/ride-detail-map.ts | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts new file mode 100644 index 0000000..bd19412 --- /dev/null +++ b/web/frontend/ride-detail-map.ts @@ -0,0 +1,130 @@ +import { clamp } from "./ride-detail-logic.js"; +import type { ClimbBounds, RideDetailColors, RideProfilePoint, RideRoute } from "./types.js"; +import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet"; + +type LeafletApi = typeof import("leaflet"); + +interface RideDetailMapOptions { + leaflet: LeafletApi; + element: HTMLElement; + route: RideRoute; + points: RideProfilePoint[]; + climbs: ClimbBounds[]; + colors: RideDetailColors; +} + +export class RideDetailMap { + private readonly leaflet: LeafletApi; + private readonly points: RideProfilePoint[]; + private readonly colors: RideDetailColors; + private readonly map: LeafletMap; + private readonly climbLayers: (Polyline | null)[]; + private readonly routeCursor: CircleMarker; + + constructor({ leaflet, element, route, points, climbs, colors }: RideDetailMapOptions) { + this.leaflet = leaflet; + this.points = points; + this.colors = colors; + this.map = leaflet.map(element); + const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }); + tiles.addTo(this.map); + const routeLayer = leaflet + .geoJSON(route, { + style: { color: colors.accent, weight: 4, opacity: 0.9 }, + }) + .addTo(this.map); + const bounds = routeLayer.getBounds(); + if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); + this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb)); + this.routeCursor = leaflet + .circleMarker([points[0].latitude, points[0].longitude], { + color: colors.forest, + fillColor: colors.accent, + fillOpacity: 0, + opacity: 0, + radius: 7, + weight: 3, + interactive: false, + }) + .addTo(this.map); + } + + createClimbLayer(climb: ClimbBounds): Polyline | null { + if (!this.isValidBounds(climb)) return null; + const coordinates: [number, number][] = this.points + .slice(climb.startIndex, climb.endIndex + 1) + .map((point) => [point.latitude, point.longitude]); + return this.leaflet + .polyline(coordinates, { + color: this.colors.climbRoute, + weight: 7, + opacity: 0.65, + lineCap: "round", + lineJoin: "round", + interactive: false, + }) + .addTo(this.map); + } + + isValidBounds(bounds: ClimbBounds | undefined): boolean { + return Boolean( + bounds && + Number.isInteger(bounds.startIndex) && + Number.isInteger(bounds.endIndex) && + bounds.startIndex >= 0 && + bounds.endIndex < this.points.length && + bounds.startIndex < bounds.endIndex, + ); + } + + updateClimbLayer(index: number, bounds: ClimbBounds | undefined, active: boolean): void { + const layer = this.climbLayers[index]; + if (!layer) return; + if (!bounds || !this.isValidBounds(bounds)) { + layer.setLatLngs([]); + return; + } + layer.setLatLngs( + this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude] as [number, number]), + ); + layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 }); + } + + nearestPointIndex(latitude: number, longitude: number): number { + let nearestIndex = 0; + let nearestDistance = Infinity; + for (let index = 0; index < this.points.length; index++) { + const point = this.points[index]; + const distance = this.leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); + if (distance < nearestDistance) { + nearestIndex = index; + nearestDistance = distance; + } + } + return nearestIndex; + } + + showPoint(index: number): void { + const point = this.points[clamp(index, 0, this.points.length - 1)]; + this.routeCursor.setLatLng([point.latitude, point.longitude]); + this.routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 }); + } + + clearPoint() { + this.routeCursor.setStyle({ opacity: 0, fillOpacity: 0 }); + } + + zoomToClimb(bounds: ClimbBounds | undefined): void { + if (!bounds || !this.isValidBounds(bounds)) return; + const climbPoints = this.points.slice(bounds.startIndex, bounds.endIndex + 1); + const mapBounds = this.leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude] as [number, number])); + if (mapBounds.isValid()) this.map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 }); + } + + onClick(listener: (event: LeafletMouseEvent) => void): void { + this.map.on("click", listener); + } +} |