aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components/StopMapSheet.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components/StopMapSheet.tsx')
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx575
1 files changed, 0 insertions, 575 deletions
diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx
deleted file mode 100644
index b00ca1c..0000000
--- a/src/frontend/app/components/StopMapSheet.tsx
+++ /dev/null
@@ -1,575 +0,0 @@
-import maplibregl from "maplibre-gl";
-import React, { useEffect, useMemo, useRef, useState } from "react";
-import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
-import { useApp } from "~/AppContext";
-import { REGION_DATA } from "~/config/RegionConfig";
-import { getLineColour } from "~/data/LineColors";
-import type { Stop } from "~/data/StopDataProvider";
-import { loadStyle } from "~/maps/styleloader";
-import "./StopMapSheet.css";
-
-export interface Position {
- latitude: number;
- longitude: number;
- orientationDegrees: number;
- shapeIndex?: number;
-}
-
-export interface ConsolidatedCirculationForMap {
- line: string;
- route: string;
- currentPosition?: Position;
- stopShapeIndex?: number;
- schedule?: {
- shapeId?: string;
- };
-}
-
-interface StopMapProps {
- stop: Stop;
- circulations: ConsolidatedCirculationForMap[];
-}
-
-export const StopMap: React.FC<StopMapProps> = ({
- stop,
- circulations
-}) => {
- const { theme } = useApp();
- const [styleSpec, setStyleSpec] = useState<any | null>(null);
- const mapRef = useRef<MapRef | null>(null);
- const [userPosition, setUserPosition] = useState<{
- latitude: number;
- longitude: number;
- accuracy?: number;
- } | null>(null);
- const geoWatchId = useRef<number | null>(null);
- const [viewState, setViewState] = useState(() => {
- let latitude = 42.2406;
- let longitude = -8.7207;
- if (stop.latitude && stop.longitude) {
- latitude = stop.latitude;
- longitude = stop.longitude;
- } else {
- const pos = circulations.find((c) => c.currentPosition)?.currentPosition;
- if (pos) {
- latitude = pos.latitude;
- longitude = pos.longitude;
- }
- }
- return {
- latitude,
- longitude,
- zoom: 16,
- };
- });
- const { zoom } = viewState;
- const hasFitted = useRef(false);
- const [moveTick, setMoveTick] = useState<number>(0);
- const [showAttribution, setShowAttribution] = useState(false);
- const [shapes, setShapes] = useState<Record<string, any>>({});
-
- useEffect(() => {
- circulations.forEach((c) => {
- if (
- c.schedule?.shapeId &&
- c.currentPosition?.shapeIndex !== undefined &&
- REGION_DATA.shapeEndpoint
- ) {
- const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`;
- if (!shapes[key]) {
- let url = `${REGION_DATA.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`;
- if (c.stopShapeIndex !== undefined) {
- url += `&stopShapeIndex=${c.stopShapeIndex}`;
- } else {
- url += `&stopLat=${stop.latitude}&stopLon=${stop.longitude}`;
- }
-
- fetch(url)
- .then((res) => {
- if (res.ok) return res.json();
- return null;
- })
- .then((data) => {
- if (data) {
- setShapes((prev) => ({ ...prev, [key]: data }));
- }
- })
- .catch((err) => console.error("Failed to load shape", err));
- }
- }
- });
- }, [circulations, shapes]);
-
- 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;
- loadStyle("openfreemap", theme)
- .then((style) => {
- if (mounted) setStyleSpec(style);
- })
- .catch((err) => console.error("Failed to load map style", err));
- return () => {
- mounted = false;
- };
- }, [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 busPositions = useMemo(
- () => circulations.filter((c) => !!c.currentPosition),
- [circulations]
- );
-
- const handleMapLoad = (e: any) => {
- if (hasFitted.current) return;
- hasFitted.current = true;
-
- const map = e.target;
-
- // Handle missing sprite images to suppress console warnings
- const handleStyleImageMissing = (e: any) => {
- if (!map || map.hasImage(e.id)) return;
- // Add a transparent 1x1 placeholder
- map.addImage(e.id, {
- width: 1,
- height: 1,
- data: new Uint8Array(4),
- });
- };
-
- map.on("styleimagemissing", handleStyleImageMissing);
-
- const points = computeFocusPoints();
- if (points.length === 0) return;
-
- let minLat = points[0].lat,
- maxLat = points[0].lat,
- minLon = points[0].lon,
- maxLon = points[0].lon;
- for (const p of points) {
- 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);
-
- // 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 {
- if (points.length === 1) {
- const only = points[0];
- map.jumpTo({ center: [only.lon, only.lat], zoom: 16 });
- } else {
- map.fitBounds(bounds, {
- padding: padding as any,
- duration: 700,
- maxZoom: 17,
- } as any);
- }
- } catch { }
- };
-
- 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">
- {styleSpec && (
- <Map
- mapLib={maplibregl as any}
- {...viewState}
- style={{ width: "100%", height: "100%" }}
- mapStyle={styleSpec}
- attributionControl={false}
- ref={mapRef}
- onLoad={handleMapLoad}
- onMove={(e) => {
- setViewState(e.viewState);
- setMoveTick((t) => (t + 1) % 1000000);
- }}
- >
- {/* Shapes */}
- {circulations.map((c, idx) => {
- if (
- !c.schedule?.shapeId ||
- c.currentPosition?.shapeIndex === undefined
- )
- return null;
- const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`;
- const shapeData = shapes[key];
- if (!shapeData) return null;
- const lineColor = getLineColour(c.line);
-
- return (
- <Source
- key={idx}
- id={`shape-${idx}`}
- type="geojson"
- data={shapeData}
- >
- <Layer
- id={`layer-border-${idx}`}
- type="line"
- paint={{
- "line-color": "#000000",
- "line-width": 6,
- }}
- />
- <Layer
- id={`layer-inner-${idx}`}
- type="line"
- paint={{
- "line-color": lineColor.background,
- "line-width": 4,
- }}
- />
- </Source>
- );
- })}
-
- {/* Stop marker (center) */}
- {stop.latitude && stop.longitude && (
- <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"
- floodOpacity={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>
- )}
-
- {/* 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 = getLineColour(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={{
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- gap: labelGap,
- transform: `rotate(${p.orientationDegrees}deg)`,
- transformOrigin: "center center",
- }}
- >
- <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>
- </Marker>
- );
- });
- })()}
- </Map>
- )}
-
- <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 className={`map-attribution ${showAttribution ? "open" : ""}`}>
- <button
- type="button"
- aria-label="Mostrar atribución del mapa"
- aria-expanded={showAttribution}
- onClick={() => setShowAttribution((open) => !open)}
- className="map-attribution__toggle"
- >
- i
- </button>
- <div className="map-attribution__panel">
- <span>OpenFreeMap © OpenMapTiles data from </span>
- <a
- href="https://www.openstreetmap.org/copyright"
- target="_blank"
- rel="noreferrer"
- >
- OpenStreetMap
- </a>
- </div>
- </div>
- </div>
- );
-};