diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-15 18:00:59 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-15 18:00:59 +0100 |
| commit | 4b9c57dc6547d0c9d105ac3767dcc90da758a25d (patch) | |
| tree | 5150d5494e7591df2fe6d83d42fc4642e9b0d1b1 /src/frontend/app/components | |
| parent | f349c491284c0cb007a97c9a11220cc00adbb64f (diff) | |
Refactor code structure for improved readability and maintainability
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.css | 66 | ||||
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.tsx | 353 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.css | 31 |
3 files changed, 328 insertions, 122 deletions
diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css index 7a3b88c..5125ff0 100644 --- a/src/frontend/app/components/StopMapSheet.css +++ b/src/frontend/app/components/StopMapSheet.css @@ -6,10 +6,74 @@ border: 1px solid var(--border-color); margin-block-start: 0; flex-shrink: 0; + position: relative; } @media (max-width: 640px) { .stop-map-container { - height: 25vh; + height: 30vh; } } + +/* Floating controls */ +.map-floating-controls { + position: absolute; + left: 8px; + top: 8px; + display: flex; + gap: 8px; + z-index: 2; +} + +.center-btn { + appearance: none; + border: 1px solid rgba(0,0,0,0.15); + background: color-mix(in oklab, var(--background-color, #fff) 85%, transparent); + color: var(--text-primary, #111); + padding: 6px; + border-radius: 6px; + font-size: 12px; + line-height: 1; + box-shadow: 0 1px 2px rgba(0,0,0,0.15); + cursor: pointer; +} + +/* User location dot */ +.user-dot { + position: relative; + width: 22px; + height: 22px; +} + +.user-dot__core { + position: absolute; + left: 50%; + top: 50%; + width: 10px; + height: 10px; + margin-left: -5px; + margin-top: -5px; + background: #2a6df4; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +.user-dot__pulse { + position: absolute; + left: 50%; + top: 50%; + width: 22px; + height: 22px; + margin-left: -11px; + margin-top: -11px; + border-radius: 50%; + background: rgba(42, 109, 244, 0.25); + animation: userPulse 1.8s ease-out infinite; +} + +@keyframes userPulse { + 0% { transform: scale(0.6); opacity: 0.8; } + 70% { transform: scale(1.2); opacity: 0.15; } + 100% { transform: scale(1.4); opacity: 0; } +} 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<StopMapProps> = ({ const [styleSpec, setStyleSpec] = useState<any | null>(null); const mapRef = useRef<MapRef | null>(null); const hasFitBounds = useRef(false); + const [userPosition, setUserPosition] = useState<{ + latitude: number; + longitude: number; + accuracy?: number; + } | null>(null); + const geoWatchId = useRef<number | null>(null); + const [zoom, setZoom] = useState<number>(16); + const [moveTick, setMoveTick] = useState<number>(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<StopMapProps> = ({ }; }, [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<StopMapProps> = ({ 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<StopMapProps> = ({ 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 ( <div className="stop-map-container"> @@ -129,90 +246,130 @@ export const StopMap: React.FC<StopMapProps> = ({ mapStyle={styleSpec} attributionControl={false} ref={mapRef} + onMove={(e) => { + setZoom(e.viewState.zoom); + setMoveTick((t) => (t + 1) % 1000000); + }} > - <NavigationControl position="top-left" /> + {/* Compact attribution (closed by default) */} + <AttributionControl position="bottom-left" compact /> {/* Stop marker (center) */} {stop.latitude && stop.longitude && ( - <Marker - longitude={stop.longitude} - latitude={stop.latitude} - anchor="bottom" - > - <div - style={{ - width: 14, - height: 14, - background: "#1976d2", - border: "2px solid white", - borderRadius: "5%", - boxShadow: "0 0 0 2px rgba(0,0,0,0.2)", - }} - title={`Stop ${stop.stopId}`} - /> + <Marker longitude={stop.longitude} latitude={stop.latitude} anchor="bottom"> + <div title={`Stop ${stop.stopId}`}> + <svg width="28" height="36" viewBox="0 0 28 36"> + <defs> + <filter id="drop" x="-20%" y="-20%" width="140%" height="140%"> + <feDropShadow dx="0" dy="1" stdDeviation="1" flood-opacity="0.35" /> + </filter> + </defs> + <path d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z" fill="#1976d2" stroke="#fff" strokeWidth="2" filter="url(#drop)" /> + <circle cx="14" cy="13" r="5" fill="#fff" /> + <circle cx="14" cy="13" r="3" fill="#1976d2" /> + </svg> + </div> </Marker> )} - {/* Bus markers with heading */} - {busPositions.map((c, idx) => { - const p = c.currentPosition!; - const lineColor = getLineColor(region, c.line); - return ( - <Marker - key={idx} - longitude={p.longitude} - latitude={p.latitude} - anchor="center" - > - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: 2, - transform: `rotate(${p.orientationDegrees}deg)`, - transformOrigin: "center center", - }} - > - {/* Line number above */} + {/* User position marker (if available) */} + {userPosition && ( + <Marker longitude={userPosition.longitude} latitude={userPosition.latitude} anchor="center"> + <div className="user-dot" title="Your location"> + <div className="user-dot__pulse" /> + <div className="user-dot__core" /> + </div> + </Marker> + )} + + {/* 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 ( + <Marker key={idx} longitude={p.longitude} latitude={p.latitude} anchor="center"> <div style={{ - background: lineColor.background, - color: lineColor.text, - padding: "2px 4px", - borderRadius: 4, - fontSize: 10, - fontWeight: 700, - lineHeight: 1, - border: "1px solid #fff", - boxShadow: "0 1px 2px rgba(0,0,0,0.3)", - transform: `rotate(${-p.orientationDegrees}deg)`, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: labelGap, + transform: `rotate(${p.orientationDegrees}deg)`, + transformOrigin: "center center", }} > - {c.line} + <svg + width="20" + height="20" + viewBox="0 0 24 24" + style={{ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))" }} + > + <path d="M12 2 L20 22 L12 18 L4 22 Z" fill={lineColor.background} stroke="#fff" strokeWidth="1.5" /> + </svg> + {showLabel && ( + <div + style={{ + background: lineColor.background, + color: lineColor.text, + padding: "2px 4px", + borderRadius: 4, + fontSize: 10, + fontWeight: 700, + lineHeight: 1, + border: "1px solid #fff", + boxShadow: "0 1px 2px rgba(0,0,0,0.3)", + transform: `rotate(${-p.orientationDegrees}deg)`, + pointerEvents: "none", + zIndex: 0, + }} + > + {c.line} + </div> + )} </div> - {/* Arrow pointing direction */} - <svg - width="20" - height="20" - viewBox="0 0 24 24" - style={{ - filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))", - }} - > - <path - d="M12 2 L20 22 L12 18 L4 22 Z" - fill={lineColor.background} - stroke="#fff" - strokeWidth="1.5" - /> - </svg> - </div> - </Marker> - ); - })} + </Marker> + ); + }); + })()} </Map> )} + {/* Floating controls */} + <div className="map-floating-controls"> + <button type="button" aria-label="Center" className="center-btn" onClick={handleCenter} title="Center view"> + <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"> + <circle cx="12" cy="12" r="3" fill="currentColor"/> + <path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/> + <circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.5"/> + </svg> + </button> + </div> </div> ); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css index ca136d8..4d6a3a8 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -2,7 +2,6 @@ font-size: 0.9rem; color: var(--subtitle-color); text-align: center; - margin-bottom: 1rem; padding: 0.5rem; } @@ -77,38 +76,24 @@ } /* Time color states */ -.consolidated-circulation-card .arrival-time.time-running { - color: #22c55e; -} - +.consolidated-circulation-card .arrival-time.time-running, .consolidated-circulation-card .arrival-time.time-running svg { color: #22c55e; } -.consolidated-circulation-card .arrival-time.time-delayed { - color: #09106e; -} - +.consolidated-circulation-card .arrival-time.time-delayed, .consolidated-circulation-card .arrival-time.time-delayed svg { - color: #09106e; -} - -/* Scheduled-only: dark blue in light mode, softer blue in dark mode */ -.consolidated-circulation-card .arrival-time.time-scheduled { - color: #0b3d91; /* dark blue */ + color: #ff6a00; } +.consolidated-circulation-card .arrival-time.time-scheduled, .consolidated-circulation-card .arrival-time.time-scheduled svg { - color: #0b3d91; + color: #0b3d91; /* dark blue */ } -@media (prefers-color-scheme: dark) { - .consolidated-circulation-card .arrival-time.time-scheduled { - color: #8fb4ff; /* lighten for dark backgrounds */ - } - .consolidated-circulation-card .arrival-time.time-scheduled svg { - color: #8fb4ff; - } +[data-theme="dark"] .consolidated-circulation-card .arrival-time.time-scheduled, +[data-theme="dark"] .consolidated-circulation-card .arrival-time.time-scheduled svg { + color: #8fb4ff; /* lighten for dark backgrounds */ } .consolidated-circulation-card .distance-info { |
