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 <noreply@anthropic.com>
This commit is contained in:
parent
0724dd017f
commit
e89d8ed821
3 changed files with 99 additions and 9 deletions
|
|
@ -70,6 +70,15 @@ async def _fetch_trips(vehicle_id: int, day: date) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = row[0]
|
payload: dict[str, Any] = row[0]
|
||||||
if "error" in payload:
|
if "error" in payload:
|
||||||
raise HTTPException(status_code=404, detail=payload["error"])
|
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
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
53
db/migrations/20260601000024_fn_vehicle_day_track.sql
Normal file
53
db/migrations/20260601000024_fn_vehicle_day_track.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -829,6 +829,32 @@ function _drawVehicleDayPaths(map, vid, payload, fixedColor) {
|
||||||
// independently from TRIP_PALETTE so the day's segments are visually
|
// independently from TRIP_PALETTE so the day's segments are visually
|
||||||
// distinguishable.
|
// distinguishable.
|
||||||
_clearVehicleLayers(map, vid);
|
_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);
|
const trips = (payload.trips || []).filter(t => t.path && t.path.coordinates);
|
||||||
if (trips.length === 0) return;
|
if (trips.length === 0) return;
|
||||||
const features = trips.map(t => ({
|
const features = trips.map(t => ({
|
||||||
|
|
@ -860,10 +886,12 @@ function _drawVehicleDayPaths(map, vid, payload, fixedColor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _clearVehicleLayers(map, vid) {
|
function _clearVehicleLayers(map, vid) {
|
||||||
const layerId = `vroute-line-${vid}`;
|
for (const layerId of [`vroute-line-${vid}`, `vtrack-line-${vid}`]) {
|
||||||
const srcId = `vroute-${vid}`;
|
|
||||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
for (const srcId of [`vroute-${vid}`, `vtrack-${vid}`]) {
|
||||||
if (map.getSource(srcId)) map.removeSource(srcId);
|
if (map.getSource(srcId)) map.removeSource(srcId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderDock(map, els) {
|
function _renderDock(map, els) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue