From e89d8ed82188f6afe1a49c243d7204d1285980c7 Mon Sep 17 00:00:00 2001 From: kianiadee Date: Fri, 29 May 2026 10:31:52 +0300 Subject: [PATCH] Continuous day-track line under per-trip segments Trip days split on reporting gaps rendered as disconnected coloured segments. Add serve.fn_vehicle_day_track (one primary-device LineString for the EAT day), merge it into the trips API as day_track, and draw it as a faint base line beneath the per-trip colours so the route reads as one continuous drive. Co-Authored-By: Claude Opus 4.8 --- app/routers/views.py | 19 +++++-- .../20260601000024_fn_vehicle_day_track.sql | 53 +++++++++++++++++++ web/fleet-core.js | 36 +++++++++++-- 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 db/migrations/20260601000024_fn_vehicle_day_track.sql diff --git a/app/routers/views.py b/app/routers/views.py index 5d2836c..aeb2398 100644 --- a/app/routers/views.py +++ b/app/routers/views.py @@ -65,11 +65,20 @@ async def _fetch_trips(vehicle_id: int, day: date) -> dict[str, Any]: "SELECT serve.fn_vehicle_trips(%s, %s)", (vehicle_id, day) ) row = await cur.fetchone() - if row is None or row[0] is None: - raise HTTPException(status_code=500, detail="serve.fn_vehicle_trips returned NULL") - payload: dict[str, Any] = row[0] - if "error" in payload: - raise HTTPException(status_code=404, detail=payload["error"]) + if row is None or row[0] is None: + raise HTTPException(status_code=500, detail="serve.fn_vehicle_trips returned NULL") + payload: dict[str, Any] = row[0] + if "error" in payload: + raise HTTPException(status_code=404, detail=payload["error"]) + # Continuous day track (GeoJSON LineString of every fix, in order) so the + # frontend can draw one unbroken route under the per-trip coloured + # segments — trip splits on reporting gaps no longer look like the + # vehicle teleported. + await cur.execute( + "SELECT serve.fn_vehicle_day_track(%s, %s)", (vehicle_id, day) + ) + track_row = await cur.fetchone() + payload["day_track"] = track_row[0] if track_row and track_row[0] is not None else None return payload diff --git a/db/migrations/20260601000024_fn_vehicle_day_track.sql b/db/migrations/20260601000024_fn_vehicle_day_track.sql new file mode 100644 index 0000000..4af8af0 --- /dev/null +++ b/db/migrations/20260601000024_fn_vehicle_day_track.sql @@ -0,0 +1,53 @@ +-- migrate:up +-- +-- Continuous day track for a vehicle: one GeoJSON LineString of every fix in +-- the EAT day, in time order. serve.fn_vehicle_trips returns each trip as its +-- own ST_MakeLine, so on the map a day with reporting-gap trip splits reads as +-- several disconnected segments. The frontend draws this track as a faint base +-- line under the coloured per-trip segments, so the route looks like one +-- continuous drive while individual trips stay highlighted. +-- +-- EAT day boundary matches serve.fn_vehicle_trips (UTC+3). Light ST_Simplify +-- (~3 m) trims redundant points to keep the payload small without visibly +-- changing the shape. Returns NULL when there are fewer than 2 fixes. + +CREATE OR REPLACE FUNCTION serve.fn_vehicle_day_track( + p_vehicle_id bigint, + p_date_eat date +) RETURNS jsonb +LANGUAGE sql STABLE +AS $$ + WITH bounds AS ( + SELECT (p_date_eat::timestamp - interval '3 hours') AT TIME ZONE 'UTC' AS day_start + ), + -- Use one device's fixes (tracker-first, same canonical pick as + -- fn_vehicle_trips) so a camera-paired vehicle's track doesn't zig-zag + -- between the tracker and camera positions. + primary_imei AS ( + SELECT d.imei + FROM domain.devices d + WHERE d.vehicle_id = p_vehicle_id + ORDER BY CASE d.device_type WHEN 'tracker' THEN 0 ELSE 1 END, d.imei + LIMIT 1 + ), + pts AS ( + SELECT ph.geom, ph.occurred_at + FROM state.position_history ph, bounds b, primary_imei pi + WHERE ph.vehicle_id = p_vehicle_id + AND ph.imei = pi.imei + AND ph.occurred_at >= b.day_start + AND ph.occurred_at < b.day_start + interval '24 hours' + ) + SELECT CASE + WHEN count(*) >= 2 + THEN ST_AsGeoJSON( + ST_Simplify(ST_MakeLine(geom ORDER BY occurred_at), 0.00003) + )::jsonb + ELSE NULL + END + FROM pts; +$$; + +-- migrate:down + +DROP FUNCTION IF EXISTS serve.fn_vehicle_day_track(bigint, date); diff --git a/web/fleet-core.js b/web/fleet-core.js index a8e344a..a4b014f 100644 --- a/web/fleet-core.js +++ b/web/fleet-core.js @@ -829,6 +829,32 @@ function _drawVehicleDayPaths(map, vid, payload, fixedColor) { // independently from TRIP_PALETTE so the day's segments are visually // distinguishable. _clearVehicleLayers(map, vid); + + // Continuous base track: one unbroken line of the whole day's fixes, drawn + // first (underneath) so the coloured per-trip segments sit on top. This is + // what makes a day with trip splits read as one continuous route instead of + // disconnected pieces. + const track = payload.day_track; + if (track && track.coordinates && track.coordinates.length >= 2) { + const tSrc = `vtrack-${vid}`; + const tLayer = `vtrack-line-${vid}`; + map.addSource(tSrc, { + type: 'geojson', + data: { type: 'Feature', geometry: track, properties: {} }, + }); + map.addLayer({ + id: tLayer, + type: 'line', + source: tSrc, + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { + 'line-color': fixedColor || '#94a3b8', + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 2, 17, 3], + 'line-opacity': 0.35, + }, + }); + } + const trips = (payload.trips || []).filter(t => t.path && t.path.coordinates); if (trips.length === 0) return; const features = trips.map(t => ({ @@ -860,10 +886,12 @@ function _drawVehicleDayPaths(map, vid, payload, fixedColor) { } function _clearVehicleLayers(map, vid) { - const layerId = `vroute-line-${vid}`; - const srcId = `vroute-${vid}`; - if (map.getLayer(layerId)) map.removeLayer(layerId); - if (map.getSource(srcId)) map.removeSource(srcId); + for (const layerId of [`vroute-line-${vid}`, `vtrack-line-${vid}`]) { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } + for (const srcId of [`vroute-${vid}`, `vtrack-${vid}`]) { + if (map.getSource(srcId)) map.removeSource(srcId); + } } function _renderDock(map, els) {