aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/StopMapSheet.css66
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx353
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css31
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 {