diff options
Diffstat (limited to 'src')
8 files changed, 227 insertions, 15 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index 6423c2f..f1f5f4a 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -46,6 +46,38 @@ public class VigoController : ControllerBase } } + [HttpGet("GetShape")] + public async Task<IActionResult> GetShape( + [FromQuery] string shapeId, + [FromQuery] int startPointIndex = 0 + ) + { + // Include a significant number of previous points to ensure continuity and context + // Backtrack 50 points to cover any potential gaps or dense point sequences + var adjustedStartIndex = Math.Max(0, startPointIndex - 50); + var path = await _shapeService.GetShapePathAsync(shapeId, adjustedStartIndex); + if (path == null) + { + return NotFound(); + } + + // Convert to GeoJSON LineString + var coordinates = path.Select(p => new[] { p.Longitude, p.Latitude }).ToList(); + + var geoJson = new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = coordinates + }, + properties = new { } + }; + + return Ok(geoJson); + } + [HttpGet("GetStopTimetable")] public async Task<IActionResult> GetStopTimetable( [FromQuery] int stopId, @@ -278,6 +310,7 @@ public class VigoController : ControllerBase Minutes = (int)(closestCirculation.CallingDateTime()!.Value - now).TotalMinutes, TripId = closestCirculation.TripId, ServiceId = closestCirculation.ServiceId, + ShapeId = closestCirculation.ShapeId, }, RealTime = new RealTimeData { @@ -316,6 +349,7 @@ public class VigoController : ControllerBase Minutes = (int)(sched.CallingDateTime()!.Value - now).TotalMinutes, TripId = sched.TripId, ServiceId = sched.ServiceId, + ShapeId = sched.ShapeId, }, RealTime = null }); diff --git a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs index bded78b..37b76ee 100644 --- a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs @@ -60,6 +60,23 @@ public class ShapeTraversalService } } + public async Task<List<Position>?> GetShapePathAsync(string shapeId, int startIndex) + { + var shape = await LoadShapeAsync(shapeId); + if (shape == null) return null; + + var result = new List<Position>(); + // Ensure startIndex is within bounds + if (startIndex < 0) startIndex = 0; + if (startIndex >= shape.Points.Count) return result; + + for (int i = startIndex; i < shape.Points.Count; i++) + { + result.Add(TransformToLatLng(shape.Points[i])); + } + return result; + } + /// <summary> /// Calculates the bus position by reverse-traversing the shape from the stop location /// </summary> @@ -78,13 +95,15 @@ public class ShapeTraversalService int closestPointIndex = FindClosestPointIndex(shape.Points, stopLocation); // Traverse backwards from the closest point to find the position at the given distance - var (busPoint, forwardPoint) = TraverseBackwards(shape.Points.ToArray(), closestPointIndex, distanceMeters); + var (busPoint, forwardIndex) = TraverseBackwards(shape.Points.ToArray(), closestPointIndex, distanceMeters); if (busPoint == null) { return null; } + var forwardPoint = shape.Points[forwardIndex]; + // Compute orientation in EPSG:25829 (meters): 0°=North, 90°=East (azimuth) var dx = forwardPoint.X - busPoint.X; // Easting difference var dy = forwardPoint.Y - busPoint.Y; // Northing difference @@ -94,19 +113,20 @@ public class ShapeTraversalService // Transform from EPSG:25829 (meters) to EPSG:4326 (lat/lng) var pos = TransformToLatLng(busPoint); pos.OrientationDegrees = (int)Math.Round(bearing); + pos.ShapeIndex = forwardIndex; return pos; } /// <summary> /// Traverses backwards along the shape from a starting point by the specified distance /// </summary> - private (Epsg25829 point, Epsg25829 forward) TraverseBackwards(Epsg25829[] shapePoints, int startIndex, double distanceMeters) + private (Epsg25829 point, int forwardIndex) TraverseBackwards(Epsg25829[] shapePoints, int startIndex, double distanceMeters) { if (startIndex <= 0) { // Already at the beginning, return the first point var forwardIdx = Math.Min(1, shapePoints.Length - 1); - return (shapePoints[0], shapePoints[forwardIdx]); + return (shapePoints[0], forwardIdx); } double remainingDistance = distanceMeters; @@ -123,7 +143,7 @@ public class ShapeTraversalService var ratio = remainingDistance / segmentDistance; var interpolated = InterpolatePoint(shapePoints[currentIndex], shapePoints[currentIndex - 1], ratio); // Forward direction is towards the stop (increasing index direction) - return (interpolated, shapePoints[currentIndex]); + return (interpolated, currentIndex); } remainingDistance -= segmentDistance; @@ -131,7 +151,7 @@ public class ShapeTraversalService } // We've reached the beginning of the shape - var fwd = shapePoints[Math.Min(1, shapePoints.Length - 1)]; + var fwd = Math.Min(1, shapePoints.Length - 1); return (shapePoints[0], fwd); } diff --git a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs index a21aa60..2ac3206 100644 --- a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs +++ b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs @@ -23,6 +23,7 @@ public class ScheduleData public required int Minutes { get; set; } public required string ServiceId { get; set; } public required string TripId { get; set; } + public string? ShapeId { get; set; } } public class Position @@ -30,4 +31,5 @@ public class Position public required double Latitude { get; set; } public required double Longitude { get; set; } public int OrientationDegrees { get; set; } + public int ShapeIndex { get; set; } } diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index 1799f74..67435b4 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -1,12 +1,14 @@ import maplibregl from "maplibre-gl"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map, { - Marker, - type MapRef + Layer, + Marker, + Source, + type MapRef } from "react-map-gl/maplibre"; import { Sheet } from "react-modal-sheet"; import { useApp } from "~/AppContext"; -import type { RegionId } from "~/config/RegionConfig"; +import { getRegionConfig, type RegionId } from "~/config/RegionConfig"; import { getLineColor } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; @@ -16,12 +18,16 @@ export interface Position { latitude: number; longitude: number; orientationDegrees: number; + shapeIndex?: number; } export interface ConsolidatedCirculationForMap { line: string; route: string; currentPosition?: Position; + schedule?: { + shapeId?: string; + }; } interface StopMapModalProps { @@ -45,6 +51,9 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ const [styleSpec, setStyleSpec] = useState<any | null>(null); const mapRef = useRef<MapRef | null>(null); const hasFitBounds = useRef(false); + const [shapeData, setShapeData] = useState<any | null>(null); + + const regionConfig = getRegionConfig(region); // Filter circulations that have GPS coordinates const busesWithPosition = useMemo( @@ -117,7 +126,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); } else { mapRef.current.fitBounds(bounds, { - padding: 24, + padding: 80, duration: 500, maxZoom: 17, } as any); @@ -190,9 +199,42 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ useEffect(() => { if (!isOpen) { hasFitBounds.current = false; + setShapeData(null); } }, [isOpen]); + // Fetch shape for selected bus + useEffect(() => { + if ( + !isOpen || + !selectedBus || + !selectedBus.schedule?.shapeId || + selectedBus.currentPosition?.shapeIndex === undefined || + !regionConfig.shapeEndpoint + ) { + setShapeData(null); + return; + } + + const shapeId = selectedBus.schedule.shapeId; + const shapeIndex = selectedBus.currentPosition.shapeIndex; + + fetch( + `${regionConfig.shapeEndpoint}?shapeId=${shapeId}&startPointIndex=${shapeIndex}` + ) + .then((res) => { + if (res.ok) return res.json(); + return null; + }) + .then((data) => { + if (data) { + setShapeData(data); + handleCenter(); + } + }) + .catch((err) => console.error("Failed to load shape", err)); + }, [isOpen, selectedBus, regionConfig.shapeEndpoint]); + if (busesWithPosition.length === 0) { return null; // Don't render if no buses with GPS coordinates } @@ -217,12 +259,45 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ longitude: center.longitude, zoom: 16, }} - style={{ width: "100%", height: "320px" }} + style={{ width: "100%", height: "50vh" }} mapStyle={styleSpec} - attributionControl={false} + attributionControl={{compact: false, customAttribution: "Concello de Vigo & Viguesa de Transportes SL"}} ref={mapRef} interactive={true} > + {/* Shape Layer */} + {shapeData && selectedBus && ( + <Source id="route-shape" type="geojson" data={shapeData}> + <Layer + id="route-shape-border" + type="line" + paint={{ + "line-color": "#000000", + "line-width": 5, + "line-opacity": 0.6, + }} + layout={{ + "line-cap": "round", + "line-join": "round", + }} + /> + <Layer + id="route-shape-inner" + type="line" + paint={{ + "line-color": getLineColor(region, selectedBus.line) + .background, + "line-width": 3, + "line-opacity": 0.7, + }} + layout={{ + "line-cap": "round", + "line-join": "round", + }} + /> + </Source> + )} + {/* Stop marker */} {stop.latitude && stop.longitude && ( <Marker @@ -290,7 +365,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ <path d="M12 2 L22 22 L12 17 L2 22 Z" fill={getLineColor(region, selectedBus.line).background} - stroke="#fff" + stroke="#000" strokeWidth="2" strokeLinejoin="round" /> diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css index 6b2e8ed..7c96c2b 100644 --- a/src/frontend/app/components/StopMapSheet.css +++ b/src/frontend/app/components/StopMapSheet.css @@ -1,7 +1,7 @@ /* Stop map container */ .stop-map-container { width: 100%; - height: 300px; + height: 50vh; overflow: hidden; border: 1px solid var(--border-color); margin-block-start: 0; diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx index 71a1095..7dab82b 100644 --- a/src/frontend/app/components/StopMapSheet.tsx +++ b/src/frontend/app/components/StopMapSheet.tsx @@ -1,8 +1,8 @@ import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import Map, { Marker, type MapRef } from "react-map-gl/maplibre"; +import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; -import type { RegionId } from "~/config/RegionConfig"; +import { getRegionConfig, type RegionId } from "~/config/RegionConfig"; import { getLineColor } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; @@ -12,12 +12,16 @@ export interface Position { latitude: number; longitude: number; orientationDegrees: number; + shapeIndex?: number; } export interface ConsolidatedCirculationForMap { line: string; route: string; currentPosition?: Position; + schedule?: { + shapeId?: string; + }; } interface StopMapProps { @@ -44,6 +48,36 @@ export const StopMap: React.FC<StopMapProps> = ({ const [zoom, setZoom] = useState<number>(16); const [moveTick, setMoveTick] = useState<number>(0); const [showAttribution, setShowAttribution] = useState(false); + const [shapes, setShapes] = useState<Record<string, any>>({}); + + const regionConfig = getRegionConfig(region); + + useEffect(() => { + circulations.forEach((c) => { + if ( + c.schedule?.shapeId && + c.currentPosition?.shapeIndex !== undefined && + regionConfig.shapeEndpoint + ) { + const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; + if (!shapes[key]) { + fetch( + `${regionConfig.shapeEndpoint}?shapeId=${c.schedule.shapeId}&startPointIndex=${c.currentPosition.shapeIndex}` + ) + .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, regionConfig.shapeEndpoint, shapes]); type Pt = { lat: number; lon: number }; const haversineKm = (a: Pt, b: Pt) => { @@ -308,6 +342,44 @@ export const StopMap: React.FC<StopMapProps> = ({ 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 = getLineColor(region, 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 && ( diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index 8acfbbf..a6ffdf8 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -7,6 +7,7 @@ export interface RegionConfig { estimatesEndpoint: string; consolidatedCirculationsEndpoint: string | null; timetableEndpoint: string | null; + shapeEndpoint: string | null; defaultCenter: [number, number]; // [lat, lng] bounds?: { sw: [number, number]; @@ -25,6 +26,7 @@ export const REGIONS: Record<RegionId, RegionConfig> = { estimatesEndpoint: "/api/vigo/GetStopEstimates", consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", timetableEndpoint: "/api/vigo/GetStopTimetable", + shapeEndpoint: "/api/vigo/GetShape", defaultCenter: [42.229188855975046, -8.72246955783102], bounds: { sw: [-8.951059, 42.098923], diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index a2b2da3..024ea73 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -23,6 +23,7 @@ export interface ConsolidatedCirculation { minutes: number; serviceId: string; tripId: string; + shapeId?: string; }; realTime?: { minutes: number; @@ -32,6 +33,7 @@ export interface ConsolidatedCirculation { latitude: number; longitude: number; orientationDegrees: number; + shapeIndex?: number; }; } @@ -269,6 +271,11 @@ export default function Estimates() { line: c.line, route: c.route, currentPosition: c.currentPosition, + schedule: c.schedule + ? { + shapeId: c.schedule.shapeId, + } + : undefined, }))} isOpen={isMapModalOpen} onClose={() => setIsMapModalOpen(false)} |
