aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs34
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs30
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs2
-rw-r--r--src/frontend/app/components/StopMapModal.tsx89
-rw-r--r--src/frontend/app/components/StopMapSheet.css2
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx76
-rw-r--r--src/frontend/app/config/RegionConfig.ts2
-rw-r--r--src/frontend/app/routes/stops-$id.tsx7
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)}