From 4b9c57dc6547d0c9d105ac3767dcc90da758a25d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sat, 15 Nov 2025 18:00:59 +0100 Subject: Refactor code structure for improved readability and maintainability --- src/frontend/app/components/StopMapSheet.tsx | 353 +++++++++++++++++++-------- 1 file changed, 255 insertions(+), 98 deletions(-) (limited to 'src/frontend/app/components/StopMapSheet.tsx') diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx index 2dc85db..e87e8c8 100644 --- a/src/frontend/app/components/StopMapSheet.tsx +++ b/src/frontend/app/components/StopMapSheet.tsx @@ -1,6 +1,6 @@ import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import Map, { Marker, NavigationControl, type MapRef } from "react-map-gl/maplibre"; +import Map, { AttributionControl, Marker, type MapRef } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; import { getLineColor } from "~/data/LineColors"; import type { RegionId } from "~/data/RegionConfig"; @@ -35,6 +35,67 @@ export const StopMap: React.FC = ({ const [styleSpec, setStyleSpec] = useState(null); const mapRef = useRef(null); const hasFitBounds = useRef(false); + const [userPosition, setUserPosition] = useState<{ + latitude: number; + longitude: number; + accuracy?: number; + } | null>(null); + const geoWatchId = useRef(null); + const [zoom, setZoom] = useState(16); + const [moveTick, setMoveTick] = useState(0); + + type Pt = { lat: number; lon: number }; + const haversineKm = (a: Pt, b: Pt) => { + const R = 6371; + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLon = ((b.lon - a.lon) * Math.PI) / 180; + const lat1 = (a.lat * Math.PI) / 180; + const lat2 = (b.lat * Math.PI) / 180; + const sinDLat = Math.sin(dLat / 2); + const sinDLon = Math.sin(dLon / 2); + const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); + }; + const computeFocusPoints = (): Pt[] => { + const buses: Pt[] = []; + for (const c of busPositions) { + if (c.currentPosition) buses.push({ lat: c.currentPosition.latitude, lon: c.currentPosition.longitude }); + } + const stopPt = stop.latitude && stop.longitude ? { lat: stop.latitude, lon: stop.longitude } : null; + const userPt = userPosition ? { lat: userPosition.latitude, lon: userPosition.longitude } : null; + + if (buses.length === 0 && !stopPt && !userPt) return []; + + // Choose anchor for proximity: stop > user > average of buses + let anchor: Pt | null = stopPt || userPt || null; + if (!anchor && buses.length) { + let lat = 0, lon = 0; + for (const b of buses) { lat += b.lat; lon += b.lon; } + anchor = { lat: lat / buses.length, lon: lon / buses.length }; + } + + const nearBuses = buses + .map((p) => ({ p, d: anchor ? haversineKm(anchor, p) : 0 })) + .sort((a, b) => a.d - b.d) + .slice(0, 8) // take closest N + .filter((x) => x.d <= 8) // within 8km + .map((x) => x.p); + + const pts: Pt[] = []; + if (stopPt) pts.push(stopPt); + pts.push(...nearBuses); + if (userPt) { + // include user if not too far from anchor + const includeUser = anchor ? haversineKm(anchor, userPt) <= 10 : true; + if (includeUser) pts.push(userPt); + } + // Fallback: if no buses survived, at least return stop or user + if (pts.length === 0) { + if (stopPt) return [stopPt]; + if (userPt) return [userPt]; + } + return pts; + }; useEffect(() => { let mounted = true; @@ -48,6 +109,42 @@ export const StopMap: React.FC = ({ }; }, [theme]); + // Geolocation: request immediately without blocking UI; update when available. + useEffect(() => { + if (!("geolocation" in navigator)) return; + try { + navigator.geolocation.getCurrentPosition( + (pos) => { + setUserPosition({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + }); + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 }, + ); + geoWatchId.current = navigator.geolocation.watchPosition( + (pos) => { + setUserPosition({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + }); + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 }, + ); + } catch {} + return () => { + if (geoWatchId.current != null && "geolocation" in navigator) { + try { + navigator.geolocation.clearWatch(geoWatchId.current); + } catch {} + } + }; + }, []); + const center = useMemo(() => { if (stop.latitude && stop.longitude) { return { latitude: stop.latitude, longitude: stop.longitude }; @@ -69,18 +166,7 @@ export const StopMap: React.FC = ({ useEffect(() => { if (!styleSpec || !mapRef.current || hasFitBounds.current) return; - const points: { lat: number; lon: number }[] = []; - if (stop.latitude && stop.longitude) { - points.push({ lat: stop.latitude, lon: stop.longitude }); - } - for (const c of busPositions) { - if (c.currentPosition) { - points.push({ - lat: c.currentPosition.latitude, - lon: c.currentPosition.longitude, - }); - } - } + const points = computeFocusPoints(); if (points.length === 0) return; let minLat = points[0].lat, @@ -94,26 +180,57 @@ export const StopMap: React.FC = ({ if (p.lon > maxLon) maxLon = p.lon; } - // ~1km in degrees - const kmToDegLat = 1.0 / 111.32; // ≈0.008983 - const centerLat = (minLat + maxLat) / 2; - const kmToDegLon = kmToDegLat / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.1); - const padLat = kmToDegLat; - const padLon = kmToDegLon; - - const sw = [minLon - padLon, minLat - padLat] as [number, number]; - const ne = [maxLon + padLon, maxLat + padLat] as [number, number]; + const sw = [minLon, minLat] as [number, number]; + const ne = [maxLon, maxLat] as [number, number]; const bounds = new maplibregl.LngLatBounds(sw, ne); + // Determine predominant bus quadrant relative to stop to bias padding. + const padding: number | { top: number; right: number; bottom: number; left: number } = 24; + + // If the diagonal is huge (likely outliers sneaked in), clamp via zoom fallback try { - mapRef.current.fitBounds(bounds, { - padding: 32, - duration: 700, - maxZoom: 17, - } as any); + if (points.length === 1) { + const only = points[0]; + mapRef.current.getMap().jumpTo({ center: [only.lon, only.lat], zoom: 16 }); + } else { + mapRef.current.fitBounds(bounds, { + padding: padding as any, + duration: 700, + maxZoom: 17, + } as any); + } hasFitBounds.current = true; } catch {} - }, [styleSpec, stop.latitude, stop.longitude, busPositions]); + }, [styleSpec, stop.latitude, stop.longitude, busPositions, userPosition]); + + const handleCenter = () => { + if (!mapRef.current) return; + const pts = computeFocusPoints(); + if (pts.length === 0) return; + + let minLat = pts[0].lat, maxLat = pts[0].lat, minLon = pts[0].lon, maxLon = pts[0].lon; + for (const p of pts) { + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + } + + const sw = [minLon, minLat] as [number, number]; + const ne = [maxLon, maxLat] as [number, number]; + const bounds = new maplibregl.LngLatBounds(sw, ne); + + const padding: number | { top: number; right: number; bottom: number; left: number } = 24; + + try { + if (pts.length === 1) { + const only = pts[0]; + mapRef.current.getMap().easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); + } else { + mapRef.current.fitBounds(bounds, { padding: padding as any, duration: 500, maxZoom: 17 } as any); + } + } catch {} + }; return (
@@ -129,90 +246,130 @@ export const StopMap: React.FC = ({ mapStyle={styleSpec} attributionControl={false} ref={mapRef} + onMove={(e) => { + setZoom(e.viewState.zoom); + setMoveTick((t) => (t + 1) % 1000000); + }} > - + {/* Compact attribution (closed by default) */} + {/* Stop marker (center) */} {stop.latitude && stop.longitude && ( - -
+ +
+ + + + + + + + + + +
)} - {/* Bus markers with heading */} - {busPositions.map((c, idx) => { - const p = c.currentPosition!; - const lineColor = getLineColor(region, c.line); - return ( - -
- {/* Line number above */} + {/* User position marker (if available) */} + {userPosition && ( + +
+
+
+
+ + )} + + {/* Bus markers with heading and dynamic label spacing */} + {(() => { + const map = mapRef.current?.getMap(); + const baseGap = 6; + const thresholdPx = 22; + const gaps: number[] = new Array(busPositions.length).fill(baseGap); + if (map && zoom >= 14.5 && busPositions.length > 1) { + const pts = busPositions.map((c) => + c.currentPosition ? map.project([c.currentPosition.longitude, c.currentPosition.latitude]) : null, + ); + for (let i = 0; i < pts.length; i++) { + const pi = pts[i]; + if (!pi) continue; + let close = 0; + for (let j = 0; j < pts.length; j++) { + if (i === j) continue; + const pj = pts[j]; + if (!pj) continue; + const dx = pi.x - pj.x; + const dy = pi.y - pj.y; + if (dx * dx + dy * dy <= thresholdPx * thresholdPx) close++; + } + gaps[i] = baseGap + Math.min(3, close) * 10; + } + } + + return busPositions.map((c, idx) => { + const p = c.currentPosition!; + const lineColor = getLineColor(region, c.line); + const showLabel = zoom >= 13; + const labelGap = gaps[idx] ?? baseGap; + return ( +
- {c.line} + + + + {showLabel && ( +
+ {c.line} +
+ )}
- {/* Arrow pointing direction */} - - - -
- - ); - })} + + ); + }); + })()} )} + {/* Floating controls */} +
+ +
); }; -- cgit v1.3