Add deckgl trips visualization stack

Initial implementation of the public trips dashboard:

- db/migrations/001..005: read-only viz_anon role + thin trips_viz_v1
  view + three SECURITY DEFINER RPCs (trips_for_day, trips_for_range,
  list_cost_centres). Builds path on demand from position_history;
  coalesces missing cost_centre to 'Unassigned'. Smoke-tested against
  staging: 982 trips / 13 cost centres for 2026-04-29.

- compose/: PostgREST v12 service + trips_web Caddy service. CORS
  allow-listed to the web FQDN; viz_anon role is the only authorization.

- web/: Vite + React + TS SPA. deck.gl TripsLayer animated over
  PathLayer (whole route in low opacity), Mapbox GL dark base map,
  Zustand store, TanStack Query for fetching. Sidebar = date controls
  + cost-centre multi-select + vehicle drilldown. Timebar = scrubber
  with 1x/10x/60x/600x speeds. tsc + vite build clean.

- README + design doc updated to match the verified schema (path lives
  in tracksolid.position_history, vehicle key is imei, no down-sampling
  needed at observed volume).
This commit is contained in:
David Kiania 2026-05-01 01:13:57 +03:00
parent d5865bbe28
commit 47c86c9d7a
37 changed files with 4477 additions and 1 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
compose/.env
web/node_modules
web/dist
web/.env
web/.env.local
web/*.tsbuildinfo
.DS_Store
db_forgejo.txt

View file

@ -1 +1,83 @@
This is the site for tracksolid deckgl ui
# deckgl_tracksolid
Public dashboard that animates a day's vehicle trips from `tracksolid_db`,
colored and filtered by `cost_centre`. Stack: deck.gl `TripsLayer` over
Mapbox GL, fed by PostgREST in front of TimescaleDB.
```
React + deck.gl SPA → PostgREST → tracksolid_db (Timescale)
(web/) (compose/) public.trips_viz_v1
public.trips_for_day(date, text[])
public.trips_for_range(date, date, text[])
public.list_cost_centres()
```
The full design lives in `trips_deckgl_tracksolid.md` — read that first.
## Layout
| Path | Purpose |
| --- | --- |
| `db/migrations/` | Five SQL files. Idempotent. Apply in order. Add only `public.*` objects. |
| `compose/` | `docker-compose.yaml` for the PostgREST API + the built web container. |
| `web/` | Vite + React + TS SPA. Renders `TripsLayer` + `PathLayer` over Mapbox. |
| `trips_deckgl_tracksolid.md` | Design doc / plan. |
## First-time setup
1. **Apply migrations** to `tracksolid_db`:
```bash
psql "$ADMIN_DSN" \
-f db/migrations/001_viz_anon_role.sql \
-f db/migrations/002_trips_viz_view.sql \
-f db/migrations/003_trips_for_day_rpc.sql \
-f db/migrations/004_trips_for_range_rpc.sql \
-f db/migrations/005_list_cost_centres_rpc.sql
```
2. **Set the `viz_anon` login password** out of band (do not commit):
```sql
ALTER ROLE viz_anon LOGIN PASSWORD '<generate one>';
```
3. **Configure env**:
```bash
cp compose/.env.example compose/.env
# fill in VIZ_DATA_PASSWORD, VITE_MAPBOX_TOKEN, …
cp web/.env.example web/.env.local
# fill in VITE_API_URL (e.g. http://localhost:3000) and VITE_MAPBOX_TOKEN
```
## Local development
```bash
# 1. Start PostgREST against the staging DB
cd compose && docker compose up postgrest
# 2. Run the web app against it
cd web && pnpm install && pnpm dev
# → http://localhost:5173
```
The web app reads `VITE_API_URL` (default `http://localhost:3000`) and
`VITE_MAPBOX_TOKEN`. Without a Mapbox token the deck.gl layers still render
on a black background — fine for testing data, ugly for demos.
## Production deployment (Coolify)
Two services live alongside the existing Dekart stack:
- `postgrest` — read-only API at `api.trips.rahamafresh.com`. CORS allow-listed
to the web FQDN.
- `trips_web` — Caddy serving the built SPA at `trips.rahamafresh.com`.
Both inherit env vars from the Coolify resource panel — see `compose/.env.example`
for the canonical list.
## Security boundary
The `viz_anon` role has **no grants on `tracksolid.*`** — verified in setup. It
can only call the four RPCs in `public`, which run `SECURITY DEFINER` and only
expose the columns listed in `trips_viz_v1`. Direct queries against any
`tracksolid` table return `permission denied for schema tracksolid`.
If the public dashboard ever needs to be locked down, add Traefik basic-auth in
Coolify (same pattern Dekart uses) — no app-level changes needed.

22
compose/.env.example Normal file
View file

@ -0,0 +1,22 @@
# Trips visualization stack — copy to compose/.env (gitignored) for local dev,
# or set these in the Coolify resource's Environment Variables tab for prod.
# --- Database (read-only role created by db/migrations/001_viz_anon_role.sql) ---
VIZ_DATA_USER=viz_anon
VIZ_DATA_PASSWORD=replace-me-with-the-password-from-ALTER-ROLE
# Host where tracksolid_db is reachable.
# Local Mac dev: stage.rahamafresh.com
# Coolify prod : host.docker.internal (the Coolify host's gateway)
VIZ_DB_HOST=host.docker.internal
VIZ_DB_PORT=5433
VIZ_DB_NAME=tracksolid_db
# --- PostgREST public URL (for OpenAPI links + CORS preflight reflection) ---
PGRST_PUBLIC_URL=https://api.trips.rahamafresh.com
# --- Web app ---
TRIPS_WEB_URL=https://trips.rahamafresh.com
VITE_API_URL=https://api.trips.rahamafresh.com
# Reuse DEKART_MAPBOX_TOKEN from the existing Dekart stack — same token is fine.
VITE_MAPBOX_TOKEN=pk.xxxxxxxxxxxxxxxxxxxxxxx

View file

@ -0,0 +1,37 @@
services:
postgrest:
image: postgrest/postgrest:v12.2.3
restart: always
expose:
- "3000"
extra_hosts:
# In Coolify the DB is reached via host.docker.internal; locally we override
# VIZ_DB_HOST in .env to stage.rahamafresh.com.
- "host.docker.internal:host-gateway"
environment:
PGRST_DB_URI: postgres://${VIZ_DATA_USER:-viz_anon}:${VIZ_DATA_PASSWORD}@${VIZ_DB_HOST:-host.docker.internal}:${VIZ_DB_PORT:-5433}/${VIZ_DB_NAME:-tracksolid_db}?sslmode=disable
PGRST_DB_SCHEMAS: public
PGRST_DB_ANON_ROLE: ${VIZ_DATA_USER:-viz_anon}
PGRST_DB_MAX_ROWS: "10000"
PGRST_OPENAPI_SERVER_PROXY_URI: ${PGRST_PUBLIC_URL:-http://localhost:3000}
PGRST_SERVER_CORS_ALLOWED_ORIGINS: ${TRIPS_WEB_URL:-http://localhost:5173}
# Local-dev convenience: bind to host port 3000 so the React dev server
# can hit it without going through Caddy. Coolify deployments should
# remove this and route via the platform's reverse proxy instead.
ports:
- "3000:3000"
trips_web:
build:
context: ../web
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://api.trips.rahamafresh.com}
VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN}
restart: always
expose:
- "8080"
depends_on:
- postgrest
# No host port binding for production. For local dev, prefer running
# `pnpm dev` directly against the postgrest service above.

View file

@ -0,0 +1,16 @@
-- 001_viz_anon_role.sql
-- Creates the read-only role used by PostgREST as its anonymous identity.
-- Only granted on the public schema; never on tracksolid.* directly.
--
-- After running, set a login password out-of-band (do NOT commit):
-- ALTER ROLE viz_anon LOGIN PASSWORD '<generated>';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'viz_anon') THEN
CREATE ROLE viz_anon NOLOGIN;
END IF;
END
$$;
GRANT USAGE ON SCHEMA public TO viz_anon;

View file

@ -0,0 +1,25 @@
-- 002_trips_viz_view.sql
-- Thin metadata view: one row per trip, joined to its device's cost_centre.
-- No path here — path is built per-trip inside the RPCs (003/004) so a
-- naive SELECT * never triggers an ST_MakeLine across the whole hypertable.
--
-- 'Unassigned' bucket: NULL or whitespace-only cost_centre is coalesced.
CREATE OR REPLACE VIEW public.trips_viz_v1 AS
SELECT
t.id AS trip_id,
t.imei,
d.vehicle_name,
d.vehicle_number,
COALESCE(NULLIF(TRIM(d.cost_centre), ''), 'Unassigned') AS cost_centre,
t.start_time,
t.end_time,
t.distance_km,
t.avg_speed_kmh,
t.max_speed_kmh
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time IS NOT NULL
AND t.end_time IS NOT NULL;
GRANT SELECT ON public.trips_viz_v1 TO viz_anon;

View file

@ -0,0 +1,62 @@
-- 003_trips_for_day_rpc.sql
-- Returns one row per trip on p_date, with the path reconstructed from
-- tracksolid.position_history points falling inside the trip window.
--
-- path_geojson : LineString GeoJSON, deck.gl reads {type, coordinates} directly.
-- timestamps_rel: integer seconds elapsed since trip start, one per vertex.
-- Frontend offsets these to "seconds since 00:00 of selected date"
-- so a single TripsLayer can animate every trip on one timeline.
--
-- HAVING count >= 2 drops trips with too few points to draw a line (rare,
-- but happens for very short trips that started/ended between GPS pings).
CREATE OR REPLACE FUNCTION public.trips_for_day(
p_date date,
p_cost_centres text[] DEFAULT NULL
) RETURNS TABLE (
trip_id bigint,
imei text,
vehicle_name text,
vehicle_number text,
cost_centre text,
start_time timestamptz,
end_time timestamptz,
distance_km numeric,
path_geojson json,
timestamps_rel int[]
)
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = public, tracksolid
AS $$
WITH day_trips AS (
SELECT v.*
FROM public.trips_viz_v1 v
WHERE v.start_time::date = p_date
AND (p_cost_centres IS NULL OR v.cost_centre = ANY(p_cost_centres))
)
SELECT
dt.trip_id,
dt.imei,
dt.vehicle_name,
dt.vehicle_number,
dt.cost_centre,
dt.start_time,
dt.end_time,
dt.distance_km,
ST_AsGeoJSON(ST_MakeLine(ph.geom ORDER BY ph.gps_time))::json AS path_geojson,
array_agg(
EXTRACT(EPOCH FROM ph.gps_time - dt.start_time)::int
ORDER BY ph.gps_time
) AS timestamps_rel
FROM day_trips dt
JOIN tracksolid.position_history ph
ON ph.imei = dt.imei
AND ph.gps_time BETWEEN dt.start_time AND dt.end_time
AND ph.geom IS NOT NULL
GROUP BY dt.trip_id, dt.imei, dt.vehicle_name, dt.vehicle_number,
dt.cost_centre, dt.start_time, dt.end_time, dt.distance_km
HAVING count(ph.geom) >= 2;
$$;
GRANT EXECUTE ON FUNCTION public.trips_for_day(date, text[]) TO viz_anon;

View file

@ -0,0 +1,65 @@
-- 004_trips_for_range_rpc.sql
-- Multi-day variant of trips_for_day. The 14-day cap is enforced here in SQL
-- so PostgREST can't be tricked into asking for an absurd range via direct RPC.
CREATE OR REPLACE FUNCTION public.trips_for_range(
p_start date,
p_end date,
p_cost_centres text[] DEFAULT NULL
) RETURNS TABLE (
trip_id bigint,
imei text,
vehicle_name text,
vehicle_number text,
cost_centre text,
start_time timestamptz,
end_time timestamptz,
distance_km numeric,
path_geojson json,
timestamps_rel int[]
)
LANGUAGE plpgsql STABLE
SECURITY DEFINER
SET search_path = public, tracksolid
AS $$
BEGIN
IF p_start IS NULL OR p_end IS NULL OR p_end < p_start THEN
RAISE EXCEPTION 'Invalid date range: p_start=%, p_end=%', p_start, p_end;
END IF;
IF (p_end - p_start) > 13 THEN
RAISE EXCEPTION 'Range too large: max 14 days, got %', (p_end - p_start + 1);
END IF;
RETURN QUERY
WITH range_trips AS (
SELECT v.*
FROM public.trips_viz_v1 v
WHERE v.start_time::date BETWEEN p_start AND p_end
AND (p_cost_centres IS NULL OR v.cost_centre = ANY(p_cost_centres))
)
SELECT
rt.trip_id,
rt.imei,
rt.vehicle_name,
rt.vehicle_number,
rt.cost_centre,
rt.start_time,
rt.end_time,
rt.distance_km,
ST_AsGeoJSON(ST_MakeLine(ph.geom ORDER BY ph.gps_time))::json AS path_geojson,
array_agg(
EXTRACT(EPOCH FROM ph.gps_time - rt.start_time)::int
ORDER BY ph.gps_time
) AS timestamps_rel
FROM range_trips rt
JOIN tracksolid.position_history ph
ON ph.imei = rt.imei
AND ph.gps_time BETWEEN rt.start_time AND rt.end_time
AND ph.geom IS NOT NULL
GROUP BY rt.trip_id, rt.imei, rt.vehicle_name, rt.vehicle_number,
rt.cost_centre, rt.start_time, rt.end_time, rt.distance_km
HAVING count(ph.geom) >= 2;
END;
$$;
GRANT EXECUTE ON FUNCTION public.trips_for_range(date, date, text[]) TO viz_anon;

View file

@ -0,0 +1,22 @@
-- 005_list_cost_centres_rpc.sql
-- Lookup for the sidebar multi-select. Returns each cost_centre with its
-- vehicle count, ordered by descending count so the busiest groups appear first.
CREATE OR REPLACE FUNCTION public.list_cost_centres()
RETURNS TABLE (
cost_centre text,
vehicle_count bigint
)
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = public, tracksolid
AS $$
SELECT
COALESCE(NULLIF(TRIM(d.cost_centre), ''), 'Unassigned') AS cost_centre,
count(*) AS vehicle_count
FROM tracksolid.devices d
GROUP BY 1
ORDER BY 2 DESC, 1 ASC;
$$;
GRANT EXECUTE ON FUNCTION public.list_cost_centres() TO viz_anon;

353
trips_deckgl_tracksolid.md Normal file
View file

@ -0,0 +1,353 @@
# Trips Visualization — Daily TripsLayer by Cost Centre
## Context
`tracksolid_db` (PostgreSQL 16 + TimescaleDB on `stage.rahamafresh.com:5433`) already powers a Dekart instance for ad-hoc map exploration (see `compose/docker-compose.yaml`). The user wants a **purpose-built, public visualization** that animates a day's vehicle trips, colored and filtered by cost centre, with a 24-hour time scrubber and per-vehicle drill-down. Dekart's SQL-driven Kepler.gl UI is too rigid for this — they want a custom React app driving deck.gl directly.
Confirmed answers from clarifying questions:
| Decision | Answer |
| --- | --- |
| Delivery | Custom deck.gl React app (not Dekart, not raw Kepler.gl) |
| Department linkage | Via vehicle / device (`devices.imei` → `devices.cost_centre`) |
| Trip shape | Path is **not stored** on `trips` — must be reconstructed from `tracksolid.position_history` per trip window |
| Daily volume | 172 vehicles, ~900 trips/day, ~74k position pings/day (avg 82 pings/trip) |
| Backend | PostgREST auto-API on `tracksolid_db` |
| UX controls | Date picker (single day) + date range + cost-centre multi-select + vehicle drill-down |
| Auth / hosting | Public dashboard, no login (Coolify deployment) |
| Grouping | `cost_centre` only — no `department` column exists. NULL/blank coalesced to `'Unassigned'` and shown by default. |
Outcome: a public URL (e.g. `trips.rahamafresh.com`) where anyone can pick a date or range, toggle cost centres, scrub a time slider, and watch animated trip paths replay.
---
## Schema findings (verified against the live DB)
**Source tables (`tracksolid` schema):**
- `trips(id bigint, imei text, start_time tstz, end_time tstz, start_geom, end_geom, distance_km, avg_speed_kmh, …)` — 6,761 rows total, ~900/day. **No path column.**
- `position_history(imei text, gps_time tstz, geom geometry, lat, lng, speed, …)` — TimescaleDB hypertable, ~74k rows/day. This is where the actual GPS path lives.
- `devices(imei text PK, vehicle_name, vehicle_number, cost_centre, device_group, depot_geom, …)` — 172 rows. `cost_centre` (British spelling) has 17 distinct values: `isp` (42), `osp` (39), `fds` (22), `roll out`, `general`, `regional`, `osp patrol`, `personal`, `mtn`, `planning`, `deliveries`, `management`, `airtel`, `qehs`, plus ~22 NULL/blank.
- `v_fleet_trace` — existing view that segments `position_history` into trips by speed + 5-min idle gap. Useful as a reference for window functions; not used directly.
- `dwh_gold.dim_vehicles` — only `(vehicle_key, imei, vehicle_number, is_active)`. **Not useful** for cost-centre data; we use `tracksolid.devices` directly.
**Implication for the design:** the path for each trip is built on-the-fly by joining `position_history` to `trips` on `imei` between `start_time` and `end_time`. Wire payload at full GPS resolution is ~1.8 MB raw / ~600 KB gzipped per day — small enough that **no `ST_SimplifyPreserveTopology` down-sampling is needed** at this volume.
---
## Architecture
```
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐
│ React + deck.gl SPA │ HTTPS │ PostgREST (read) │ SQL │ tracksolid_db │
│ (Vite, TS) │ ────► │ Coolify service │ ───► │ • new view + │
│ • TripsLayer │ │ role: viz_anon │ │ RPCs in public. │
│ • PathLayer │ │ /rpc/trips_for_day │ │ • untouched: │
│ • Filters / slider │ │ │ │ tracksolid.* │
└─────────────────────┘ └────────────────────┘ └──────────────────────┘
▲ ▲
│ Mapbox GL base tiles │ host.docker.internal:5433
▼ │ (read-only role on public schema)
┌─────────────────────┐ │
│ Mapbox tiles (pk.*) │ │
└─────────────────────┘ ▼
(no schema changes to
tracksolid.*; only new
public.* view + RPC + role)
```
Three deployable units, all in the same Coolify project alongside the existing Dekart stack:
1. **DB layer (additive)** — new `public.trips_viz_v1` view + RPCs + `viz_anon` role inside `tracksolid_db`. **No mutations to existing `tracksolid.*` tables.**
2. **API layer** — PostgREST as a tiny container, read-only against the new view + RPCs.
3. **Web layer** — static React build served by Caddy under a public FQDN.
---
## Component design
### 1. Database layer (additive, in `tracksolid_db`)
Three migrations under `db/migrations/`:
```sql
-- 001_viz_anon_role.sql
CREATE ROLE viz_anon NOLOGIN;
GRANT USAGE ON SCHEMA public TO viz_anon;
-- We never grant on schema tracksolid; the role only sees what we expose in public.*
-- 002_trips_viz_view.sql
-- Thin metadata view (no path). Used as a stable contract + perm boundary.
CREATE OR REPLACE VIEW public.trips_viz_v1 AS
SELECT
t.id AS trip_id,
t.imei,
d.vehicle_name,
d.vehicle_number,
COALESCE(NULLIF(TRIM(d.cost_centre), ''), 'Unassigned') AS cost_centre,
t.start_time,
t.end_time,
t.distance_km,
t.avg_speed_kmh,
t.max_speed_kmh
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time IS NOT NULL
AND t.end_time IS NOT NULL;
GRANT SELECT ON public.trips_viz_v1 TO viz_anon;
```
```sql
-- 003_trips_for_day_rpc.sql
-- RPC that returns trips + reconstructed path for a single date.
CREATE OR REPLACE FUNCTION public.trips_for_day(
p_date date,
p_cost_centres text[] DEFAULT NULL
) RETURNS TABLE (
trip_id bigint,
imei text,
vehicle_name text,
vehicle_number text,
cost_centre text,
start_time timestamptz,
end_time timestamptz,
distance_km numeric,
path_geojson json,
timestamps_rel int[] -- seconds since trip start, per vertex (for TripsLayer)
)
LANGUAGE sql STABLE AS $$
WITH day_trips AS (
SELECT v.* FROM public.trips_viz_v1 v
WHERE v.start_time::date = p_date
AND (p_cost_centres IS NULL OR v.cost_centre = ANY(p_cost_centres))
)
SELECT
dt.trip_id,
dt.imei,
dt.vehicle_name,
dt.vehicle_number,
dt.cost_centre,
dt.start_time,
dt.end_time,
dt.distance_km,
ST_AsGeoJSON(ST_MakeLine(ph.geom ORDER BY ph.gps_time))::json AS path_geojson,
array_agg(EXTRACT(EPOCH FROM ph.gps_time - dt.start_time)::int
ORDER BY ph.gps_time) AS timestamps_rel
FROM day_trips dt
JOIN tracksolid.position_history ph
ON ph.imei = dt.imei
AND ph.gps_time BETWEEN dt.start_time AND dt.end_time
AND ph.geom IS NOT NULL
GROUP BY dt.trip_id, dt.imei, dt.vehicle_name, dt.vehicle_number,
dt.cost_centre, dt.start_time, dt.end_time, dt.distance_km
HAVING count(ph.geom) >= 2; -- need at least 2 points for a LineString
$$;
GRANT EXECUTE ON FUNCTION public.trips_for_day(date, text[]) TO viz_anon;
```
```sql
-- 004_trips_for_range_rpc.sql
-- Multi-day variant; capped at 14 days at the API layer to bound payload size.
CREATE OR REPLACE FUNCTION public.trips_for_range(
p_start date, p_end date, p_cost_centres text[] DEFAULT NULL
) RETURNS TABLE (...same shape...)
LANGUAGE sql STABLE AS $$
-- identical body, with WHERE start_time::date BETWEEN p_start AND p_end
$$;
-- 005_list_cost_centres_rpc.sql
CREATE OR REPLACE FUNCTION public.list_cost_centres()
RETURNS TABLE (cost_centre text, vehicle_count bigint)
LANGUAGE sql STABLE AS $$
SELECT COALESCE(NULLIF(TRIM(d.cost_centre), ''), 'Unassigned') AS cost_centre,
count(*) AS vehicle_count
FROM tracksolid.devices d
GROUP BY 1
ORDER BY 2 DESC;
$$;
GRANT EXECUTE ON FUNCTION public.list_cost_centres() TO viz_anon;
```
**Why thin view + path-building RPC instead of one fat view?** The path build is expensive (`ST_MakeLine` + `array_agg` over `position_history`) — we don't want it firing on every metadata-only query. The view is the perm/contract surface; RPCs do the heavy lifting only when filtered to a date.
**Why `cost_centre` (British) and not `cost_center`?** That's the actual column name in `tracksolid.devices`. We mirror it verbatim throughout — view, RPC params, frontend types — so there's never a translation layer to debug.
**Why no `ST_SimplifyPreserveTopology`?** A full day at full GPS resolution is ~1.8 MB / ~600 KB gzipped. Simplification would save bytes we can spend without thinking, and risks visual artifacts when TripsLayer interpolates between vertices. Keep raw.
### 2. API layer
Add to `compose/docker-compose.yaml` (alongside existing `dekart` service):
```yaml
postgrest:
image: postgrest/postgrest:v12.2.0
restart: always
expose: ["3000"]
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
PGRST_DB_URI: postgres://${VIZ_DATA_USER:-viz_anon}:${VIZ_DATA_PASSWORD}@host.docker.internal:5433/${DEKART_DATA_DB:-tracksolid_db}?sslmode=disable
PGRST_DB_SCHEMAS: public
PGRST_DB_ANON_ROLE: viz_anon
PGRST_OPENAPI_SERVER_PROXY_URI: https://api.trips.rahamafresh.com
PGRST_DB_MAX_ROWS: 10000 # safety cap
PGRST_SERVER_CORS_ALLOWED_ORIGINS: https://trips.rahamafresh.com
```
Coolify points the public FQDN `api.trips.rahamafresh.com``postgrest:3000`. No auth — the `viz_anon` role itself is the authorization (it can only see `public.trips_viz_v1` + the three RPCs).
`viz_anon` needs a login password set with `ALTER ROLE viz_anon LOGIN PASSWORD '…'` separately (`NOLOGIN` was shown in the migration to make grants explicit; PostgREST needs `LOGIN`).
### 3. Web layer (custom React + deck.gl SPA)
Tech stack:
- **Vite + React 18 + TypeScript**.
- **deck.gl** (`@deck.gl/react`, `@deck.gl/layers`, `@deck.gl/geo-layers`) for `TripsLayer` and `PathLayer`.
- **react-map-gl** + Mapbox GL JS — base map (reuse existing `DEKART_MAPBOX_TOKEN`).
- **TanStack Query** — fetch + cache; essential when toggling cost centres fires repeated calls.
- **Zustand** — cross-component UI state (date, selected cost centres, currentTime, focused imei).
- **date-fns** for date math; **shadcn/ui** for picker / multi-select / slider primitives.
Component tree:
```
src/
App.tsx — layout shell: <MapView /> + <Sidebar /> + <Timebar />
store/
useVizStore.ts — Zustand: { date, range, costCentres[], focusedImei, currentTime, playing, speed }
hooks/
useTripsForDay.ts — TanStack Query → /rpc/trips_for_day
useCostCentres.ts — TanStack Query → /rpc/list_cost_centres
useAnimationLoop.ts — requestAnimationFrame → store.setCurrentTime
components/
MapView.tsx — DeckGL + react-map-gl; builds [PathLayer, TripsLayer]
Sidebar/
DateControls.tsx — single-day vs range toggle
CostCentreFilter.tsx — multi-select with color swatches + per-bucket vehicle count
VehicleDrilldown.tsx — list of vehicles inside current filter; click = isolate that imei
Legend.tsx — cost_centre → color
Timebar/
TimeSlider.tsx — 086400s slider, play/pause, 1×/10×/60×/600× speed
layers/
buildLayers.ts — pure fn: (trips, currentTime, focusedImei) → Layer[]
lib/
api.ts — typed PostgREST client (no auth header)
color.ts — stable hash(cost_centre) → palette index
decode.ts — GeoJSON LineString → Float32Array of coords for TripsLayer
types/
Trip.ts — { trip_id, imei, vehicle_name, vehicle_number, cost_centre,
start_time, end_time, distance_km,
path_geojson: { type:'LineString', coordinates:[[lng,lat],…] },
timestamps_rel: number[] }
```
**Layer composition:** at any moment two layers stack:
1. `PathLayer` — full route at 30% opacity for the *focused* set (selected cost centres + drilled imei). Users see the whole route, not just the comet head.
2. `TripsLayer` — animated comets driven by `currentTime` (relative seconds since 00:00 of the selected date, **not** since trip start — so we offset each trip's `timestamps_rel` by `EXTRACT(EPOCH FROM start_time - midnight)` on the client). `trailLength` defaults to 600 s (~10 min tail), exposed as a slider.
`getColor` maps `cost_centre` → 12-color categorical palette; `'Unassigned'` always maps to a neutral grey (`#888`) so the gap is visually obvious without yelling.
**Performance budget at observed scale (172 vehicles × ~900 trips × ~82 pts):**
- Wire payload: ~600 KB gzipped per day — load once per (date, costCentres) tuple, cache via TanStack Query.
- GPU memory: TripsLayer holds positions + timestamps buffers, ~600 KB each — trivial on any laptop.
- Fetch strategy: prefetch tomorrow's date on idle.
### 4. Deployment
- New service `postgrest` in `compose/docker-compose.yaml`.
- New service `trips-web` — static Caddy container serving `dist/` from CI build.
- Two new FQDNs in Coolify: `trips.rahamafresh.com` (web), `api.trips.rahamafresh.com` (PostgREST).
- New env vars: `VIZ_DATA_USER`, `VIZ_DATA_PASSWORD`, `VITE_API_URL`, `VITE_MAPBOX_TOKEN`.
---
## Files to create / modify
### New
- `db/migrations/001_viz_anon_role.sql`
- `db/migrations/002_trips_viz_view.sql`
- `db/migrations/003_trips_for_day_rpc.sql`
- `db/migrations/004_trips_for_range_rpc.sql`
- `db/migrations/005_list_cost_centres_rpc.sql`
- `web/` — full Vite + React + TS scaffold (package.json, vite.config.ts, tsconfig.json, src/*)
- `web/Dockerfile` — multi-stage build → Caddy
- `web/Caddyfile`
- `compose/postgrest.env.example`
### Modify
- `compose/docker-compose.yaml` — add `postgrest` and `trips_web` services.
- `compose/.env.example` — add `VIZ_DATA_USER`, `VIZ_DATA_PASSWORD`, `VITE_API_URL`, `VITE_MAPBOX_TOKEN`.
### Reuse (do not modify)
- Existing `dekart` service in `compose/docker-compose.yaml` — runs alongside, untouched.
- Existing `DEKART_MAPBOX_TOKEN` — same token works for the web app's Mapbox GL.
- Existing `grafana_ro` role — *not* reused; we create a stricter `viz_anon` role to limit the public API's surface.
---
## Verification
### 1. Database smoke test (run after migrations)
```sql
-- pick a recent date with traffic
SELECT count(*) AS trip_rows,
count(DISTINCT cost_centre) AS cost_centres,
count(DISTINCT imei) AS vehicles,
sum(jsonb_array_length(path_geojson->'coordinates')) AS total_vertices
FROM public.trips_for_day(current_date - 1);
-- expect: trip_rows ~900, cost_centres ≥ 10, vehicles 50150, total_vertices ~70k
SELECT cost_centre, vehicle_count FROM public.list_cost_centres();
-- expect 'Unassigned' present plus the 17 known buckets
```
### 2. PostgREST smoke test
```bash
docker compose up -d postgrest
curl -s "http://localhost:3000/rpc/list_cost_centres" | jq '.[].cost_centre'
curl -s "http://localhost:3000/rpc/trips_for_day?p_date=2026-04-29" | jq '.[0]'
# expect a JSON object with trip_id, imei, cost_centre, path_geojson, timestamps_rel
curl -s -X POST "http://localhost:3000/rpc/trips_for_day" \
-H 'content-type: application/json' \
-d '{"p_date":"2026-04-29","p_cost_centres":["isp","osp"]}' | jq 'length'
# expect a non-zero count, less than the unfiltered total
```
### 3. Web app end-to-end (dev)
```bash
cd web && pnpm dev
# Visit http://localhost:5173, then verify:
# Cost-centre legend lists all 17 buckets + 'Unassigned' (greyed)
# Date picker defaults to yesterday; switching dates triggers fresh fetch
# Toggling 'isp' alone narrows the map to ~42 vehicles' trips
# Clicking a vehicle in drill-down isolates only that imei's path (Path + Trips layers both filter)
# Time slider scrubs 00:0024:00; play button animates at 60× by default
# Range mode toggle switches the slider to multi-day and calls /rpc/trips_for_range
# No console errors; Mapbox base map renders; deck.gl canvas overlays cleanly
```
### 4. Production deploy check
```bash
curl -I https://api.trips.rahamafresh.com/ # 200 + CORS allow trips.rahamafresh.com
# Visit https://trips.rahamafresh.com — verify the same checks as §3.
```
---
## Open items deferred to implementation
- **Trail length default** — currently 600 s; expose as a slider in case 10-minute tails feel too long for short urban trips.
- **Color palette** — start with deck.gl's 12-color categorical palette + neutral grey for `'Unassigned'`; allow `config/cost-centre-colors.json` to lock specific brand colors later.
- **Range cap**`trips_for_range` capped at 14 days at the API layer; raise once real usage shows we need more.
- **Index check** — confirm `position_history` has an index on `(imei, gps_time)`; if not, the per-trip path subquery will be slow on cold cache. (Likely already present since it's a Timescale hypertable, but verify with `\d+ tracksolid.position_history`.)
- **`viz_anon` LOGIN** — set its password via `ALTER ROLE viz_anon LOGIN PASSWORD '…'` after migrations; not committed to git.

4
web/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local

3
web/.env.example Normal file
View file

@ -0,0 +1,3 @@
# Copy to web/.env.local for dev. Vite injects VITE_* into the bundle at build time.
VITE_API_URL=http://localhost:3000
VITE_MAPBOX_TOKEN=pk.xxxxxxxxxxxxxxxxxxxxxxx

8
web/Caddyfile Normal file
View file

@ -0,0 +1,8 @@
# Caddy serves the SPA on :8080. Coolify's reverse proxy handles TLS + the
# public FQDN; this container just serves static files with SPA fallback.
:8080 {
root * /srv
encode gzip zstd
try_files {path} /index.html
file_server
}

16
web/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY . .
ARG VITE_API_URL
ARG VITE_MAPBOX_TOKEN
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_MAPBOX_TOKEN=${VITE_MAPBOX_TOKEN}
RUN pnpm build
FROM caddy:2.8-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
EXPOSE 8080

12
web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tracksolid Trips</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
web/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "deckgl-tracksolid-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 8080",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@deck.gl/core": "^9.0.27",
"@deck.gl/geo-layers": "^9.0.27",
"@deck.gl/layers": "^9.0.27",
"@deck.gl/react": "^9.0.27",
"@tanstack/react-query": "^5.51.0",
"date-fns": "^3.6.0",
"mapbox-gl": "^3.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-map-gl": "^7.1.7",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/mapbox-gl": "^3.4.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

2763
web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

50
web/src/App.tsx Normal file
View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { differenceInCalendarDays, parseISO } from 'date-fns';
import { useTrips } from './hooks/useTrips';
import { useVizStore } from './store/useVizStore';
import { MapView } from './components/MapView';
import { DateControls } from './components/Sidebar/DateControls';
import { CostCentreFilter } from './components/Sidebar/CostCentreFilter';
import { VehicleDrilldown } from './components/Sidebar/VehicleDrilldown';
import { TimeSlider } from './components/Timebar/TimeSlider';
export default function App() {
const mode = useVizStore((s) => s.mode);
const date = useVizStore((s) => s.date);
const rangeStart = useVizStore((s) => s.rangeStart);
const rangeEnd = useVizStore((s) => s.rangeEnd);
const { data: trips, isLoading, error } = useTrips();
const windowStartIso = mode === 'day' ? date : rangeStart;
const windowDays = useMemo(() => {
if (mode === 'day') return 1;
return Math.max(1, differenceInCalendarDays(parseISO(rangeEnd), parseISO(rangeStart)) + 1);
}, [mode, rangeStart, rangeEnd]);
const maxSeconds = windowDays * 86400;
return (
<div className="app">
<aside className="sidebar">
<h1>Tracksolid Trips</h1>
<DateControls />
<CostCentreFilter />
<VehicleDrilldown />
<div className="status">
{isLoading
? 'Fetching trips…'
: error
? <span className="error">{(error as Error).message}</span>
: trips
? `${trips.length} trip${trips.length === 1 ? '' : 's'} loaded`
: null}
</div>
<p className="legend-note">
Animated paths are reconstructed from <code>position_history</code> per
trip. Color = <code>cost_centre</code>; grey = unassigned device.
</p>
</aside>
<MapView trips={trips ?? []} windowStartIso={windowStartIso} />
<TimeSlider maxSeconds={maxSeconds} />
</div>
);
}

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import DeckGL from '@deck.gl/react';
import { Map } from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import type { Trip } from '../types/Trip';
import { buildLayers } from '../layers/buildLayers';
import { useVizStore } from '../store/useVizStore';
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN as string | undefined;
interface Props {
trips: Trip[];
windowStartIso: string;
}
const INITIAL_VIEW_STATE = {
longitude: 36.8219,
latitude: -1.2921,
zoom: 9,
pitch: 0,
bearing: 0,
};
export function MapView({ trips, windowStartIso }: Props) {
const currentTime = useVizStore((s) => s.currentTime);
const trailLength = useVizStore((s) => s.trailLength);
const focusedImei = useVizStore((s) => s.focusedImei);
const layers = useMemo(
() => buildLayers({ trips, windowStartIso, currentTime, trailLength, focusedImei }),
[trips, windowStartIso, currentTime, trailLength, focusedImei],
);
return (
<div className="map-area">
<DeckGL initialViewState={INITIAL_VIEW_STATE} controller layers={layers}>
{MAPBOX_TOKEN ? (
<Map
mapStyle="mapbox://styles/mapbox/dark-v11"
mapboxAccessToken={MAPBOX_TOKEN}
reuseMaps
/>
) : null}
</DeckGL>
{!MAPBOX_TOKEN ? (
<div className="banner">No VITE_MAPBOX_TOKEN set base map disabled.</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,67 @@
import { useCostCentres } from '../../hooks/useTrips';
import { useVizStore } from '../../store/useVizStore';
import { colorFor, rgbCss } from '../../lib/color';
export function CostCentreFilter() {
const { data, isLoading, error } = useCostCentres();
const selected = useVizStore((s) => s.costCentres);
const setCostCentres = useVizStore((s) => s.setCostCentres);
if (isLoading) return <div className="status">Loading cost centres</div>;
if (error) return <div className="status error">{(error as Error).message}</div>;
if (!data) return null;
const allNames = data.map((d) => d.cost_centre);
const isAll = selected === null;
const isOn = (cc: string) => isAll || selected!.includes(cc);
const toggle = (cc: string) => {
if (isAll) {
// From "all selected" → uncheck just this one
setCostCentres(allNames.filter((x) => x !== cc));
return;
}
const cur = selected!;
const next = cur.includes(cc) ? cur.filter((x) => x !== cc) : [...cur, cc];
// If user re-checks every box, collapse back to null = ALL
setCostCentres(next.length === allNames.length ? null : next);
};
return (
<div className="section">
<h2>Cost centre</h2>
<div className="input-row" style={{ gap: 6 }}>
<button
className="toggle"
style={{ padding: '4px 8px', cursor: 'pointer' }}
onClick={() => setCostCentres(null)}
>
All
</button>
<button
className="toggle"
style={{ padding: '4px 8px', cursor: 'pointer' }}
onClick={() => setCostCentres([])}
>
None
</button>
</div>
<div>
{data.map((d) => {
const on = isOn(d.cost_centre);
return (
<div
key={d.cost_centre}
className={`cc-row ${on ? '' : 'muted'}`}
onClick={() => toggle(d.cost_centre)}
>
<span className="swatch" style={{ background: rgbCss(colorFor(d.cost_centre)) }} />
<span className="name">{d.cost_centre}</span>
<span className="count">{d.vehicle_count}</span>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
import { useVizStore } from '../../store/useVizStore';
export function DateControls() {
const mode = useVizStore((s) => s.mode);
const setMode = useVizStore((s) => s.setMode);
const date = useVizStore((s) => s.date);
const setDate = useVizStore((s) => s.setDate);
const rangeStart = useVizStore((s) => s.rangeStart);
const rangeEnd = useVizStore((s) => s.rangeEnd);
const setRange = useVizStore((s) => s.setRange);
return (
<div className="section">
<h2>Time window</h2>
<div className="toggle">
<button className={mode === 'day' ? 'active' : ''} onClick={() => setMode('day')}>
Single day
</button>
<button className={mode === 'range' ? 'active' : ''} onClick={() => setMode('range')}>
Range
</button>
</div>
{mode === 'day' ? (
<div className="input-row">
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
) : (
<>
<div className="input-row">
<input
type="date"
value={rangeStart}
onChange={(e) => setRange(e.target.value, rangeEnd)}
/>
<span></span>
<input
type="date"
value={rangeEnd}
onChange={(e) => setRange(rangeStart, e.target.value)}
/>
</div>
<p className="legend-note">Server caps range at 14 days.</p>
</>
)}
</div>
);
}

View file

@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useTrips } from '../../hooks/useTrips';
import { useVizStore } from '../../store/useVizStore';
export function VehicleDrilldown() {
const { data, isLoading } = useTrips();
const focusedImei = useVizStore((s) => s.focusedImei);
const setFocusedImei = useVizStore((s) => s.setFocusedImei);
const vehicles = useMemo(() => {
if (!data) return [];
const seen = new Map<string, { imei: string; label: string; trips: number }>();
for (const t of data) {
const cur = seen.get(t.imei);
if (cur) cur.trips += 1;
else
seen.set(t.imei, {
imei: t.imei,
label: t.vehicle_name || t.vehicle_number || t.imei,
trips: 1,
});
}
return Array.from(seen.values()).sort((a, b) => b.trips - a.trips);
}, [data]);
return (
<div className="section">
<h2>Vehicles ({vehicles.length})</h2>
{isLoading ? (
<div className="status">Loading</div>
) : (
<>
{focusedImei ? (
<button
className="toggle"
style={{ padding: '4px 8px', cursor: 'pointer', alignSelf: 'flex-start' }}
onClick={() => setFocusedImei(null)}
>
Show all
</button>
) : null}
<div className="vehicle-list">
{vehicles.map((v) => (
<div
key={v.imei}
className={`vehicle-row ${focusedImei === v.imei ? 'active' : ''}`}
onClick={() => setFocusedImei(focusedImei === v.imei ? null : v.imei)}
title={v.imei}
>
{v.label} <span className="count">· {v.trips}</span>
</div>
))}
{vehicles.length === 0 ? (
<div className="vehicle-row" style={{ color: 'var(--text-dim)' }}>
No trips in this window.
</div>
) : null}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import { useVizStore, type Speed } from '../../store/useVizStore';
import { useAnimationLoop } from '../../hooks/useAnimationLoop';
const SPEEDS: Speed[] = [1, 10, 60, 600];
function fmtClock(seconds: number): string {
const sec = Math.max(0, Math.floor(seconds));
const days = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const hms = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return days > 0 ? `D${days + 1} ${hms}` : hms;
}
interface Props {
maxSeconds: number;
}
export function TimeSlider({ maxSeconds }: Props) {
const currentTime = useVizStore((s) => s.currentTime);
const setCurrentTime = useVizStore((s) => s.setCurrentTime);
const playing = useVizStore((s) => s.playing);
const setPlaying = useVizStore((s) => s.setPlaying);
const speed = useVizStore((s) => s.speed);
const setSpeed = useVizStore((s) => s.setSpeed);
useAnimationLoop(maxSeconds);
return (
<div className="timebar">
<button
className="play"
onClick={() => setPlaying(!playing)}
title={playing ? 'Pause' : 'Play'}
>
{playing ? '❚❚' : '▶'}
</button>
<span className="clock">{fmtClock(currentTime)}</span>
<input
type="range"
min={0}
max={Math.max(1, maxSeconds)}
step={1}
value={Math.min(currentTime, maxSeconds)}
onChange={(e) => setCurrentTime(Number(e.target.value))}
/>
<div className="speed">
{SPEEDS.map((sp) => (
<button
key={sp}
className={speed === sp ? 'active' : ''}
onClick={() => setSpeed(sp)}
>
{sp}×
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { useEffect, useRef } from 'react';
import { useVizStore } from '../store/useVizStore';
// Drives currentTime forward while `playing`. Wraps at the end of the window.
export function useAnimationLoop(maxSeconds: number) {
const playing = useVizStore((s) => s.playing);
const speed = useVizStore((s) => s.speed);
const setCurrentTime = useVizStore((s) => s.setCurrentTime);
const lastFrameRef = useRef<number | null>(null);
useEffect(() => {
if (!playing || maxSeconds <= 0) return;
let raf = 0;
const tick = (ts: number) => {
const last = lastFrameRef.current ?? ts;
const dtSec = (ts - last) / 1000;
lastFrameRef.current = ts;
const cur = useVizStore.getState().currentTime;
let next = cur + dtSec * speed;
if (next >= maxSeconds) next = next % maxSeconds;
setCurrentTime(next);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
lastFrameRef.current = null;
};
}, [playing, speed, maxSeconds, setCurrentTime]);
}

24
web/src/hooks/useTrips.ts Normal file
View file

@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useVizStore } from '../store/useVizStore';
export function useTrips() {
const { mode, date, rangeStart, rangeEnd, costCentres } = useVizStore();
const ccKey = costCentres ? [...costCentres].sort().join(',') : 'ALL';
return useQuery({
queryKey: ['trips', mode, mode === 'day' ? date : `${rangeStart}..${rangeEnd}`, ccKey],
queryFn: () =>
mode === 'day'
? api.tripsForDay(date, costCentres)
: api.tripsForRange(rangeStart, rangeEnd, costCentres),
});
}
export function useCostCentres() {
return useQuery({
queryKey: ['cost_centres'],
queryFn: () => api.listCostCentres(),
staleTime: 60 * 60_000,
});
}

230
web/src/index.css Normal file
View file

@ -0,0 +1,230 @@
:root {
color-scheme: dark;
--bg: #0b0d12;
--panel: #11151c;
--panel-2: #161b25;
--border: #232a36;
--text: #e6e8ec;
--text-dim: #9aa3b2;
--accent: #5b8def;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body, #root {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
button, input, select {
font: inherit;
color: inherit;
}
.app {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas:
"sidebar map"
"sidebar timebar";
height: 100vh;
}
.sidebar {
grid-area: sidebar;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px;
gap: 18px;
}
.sidebar h1 {
font-size: 16px;
margin: 0 0 4px 0;
letter-spacing: 0.02em;
}
.sidebar h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin: 0 0 6px 0;
font-weight: 600;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.input-row {
display: flex;
align-items: center;
gap: 8px;
}
.input-row input[type="date"],
.input-row select {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
flex: 1;
}
.toggle {
display: inline-flex;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.toggle button {
background: transparent;
border: 0;
padding: 6px 10px;
cursor: pointer;
color: var(--text-dim);
}
.toggle button.active {
background: var(--accent);
color: white;
}
.cc-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
}
.cc-row:hover { background: var(--panel-2); }
.cc-row .swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.cc-row .name {
flex: 1;
text-transform: capitalize;
}
.cc-row .count {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.cc-row.muted .name, .cc-row.muted .count {
color: var(--text-dim);
text-decoration: line-through;
}
.vehicle-list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-2);
}
.vehicle-row {
padding: 6px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.vehicle-row:last-child { border-bottom: 0; }
.vehicle-row:hover { background: rgba(255,255,255,0.04); }
.vehicle-row.active { background: rgba(91,141,239,0.18); }
.map-area {
grid-area: map;
position: relative;
overflow: hidden;
}
.timebar {
grid-area: timebar;
background: var(--panel);
border-top: 1px solid var(--border);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.timebar .clock {
font-variant-numeric: tabular-nums;
font-size: 14px;
min-width: 110px;
color: var(--text-dim);
}
.timebar input[type="range"] {
flex: 1;
accent-color: var(--accent);
}
.timebar .play {
background: var(--accent);
color: white;
border: 0;
width: 34px;
height: 34px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
}
.timebar .speed {
display: inline-flex;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.timebar .speed button {
background: transparent;
border: 0;
color: var(--text-dim);
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
}
.timebar .speed button.active {
background: var(--accent);
color: white;
}
.status {
font-size: 12px;
color: var(--text-dim);
padding: 4px 0;
}
.status.error { color: #ff7b72; }
.legend-note {
font-size: 11px;
color: var(--text-dim);
line-height: 1.4;
}
.banner {
position: absolute;
top: 12px;
left: 12px;
background: rgba(11, 13, 18, 0.85);
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
pointer-events: none;
}

View file

@ -0,0 +1,77 @@
import { PathLayer } from '@deck.gl/layers';
import { TripsLayer } from '@deck.gl/geo-layers';
import type { Layer } from '@deck.gl/core';
import type { Trip } from '../types/Trip';
import { colorFor } from '../lib/color';
interface NormalizedTrip extends Trip {
// path coordinates flattened into [[lng,lat], ...] (already that shape from PostGIS)
// timestamps offset to "seconds since 00:00 of the first day in view"
abs_timestamps: number[];
color: [number, number, number];
}
/**
* Offset a trip's per-vertex timestamps from "seconds since trip start"
* to "seconds since 00:00 of `windowStartIso`" so a single TripsLayer
* can animate every trip on one shared timeline.
*/
function normalize(trip: Trip, windowStartUtc: number): NormalizedTrip {
const tripStartUtc = new Date(trip.start_time).getTime() / 1000;
const offset = tripStartUtc - windowStartUtc;
return {
...trip,
abs_timestamps: trip.timestamps_rel.map((t) => t + offset),
color: colorFor(trip.cost_centre),
};
}
export interface BuildLayersArgs {
trips: Trip[];
windowStartIso: string; // 'yyyy-MM-dd' for day mode, range start for range mode
currentTime: number; // seconds since windowStart 00:00
trailLength: number; // seconds
focusedImei: string | null; // when set, dim everything else
}
export function buildLayers({
trips,
windowStartIso,
currentTime,
trailLength,
focusedImei,
}: BuildLayersArgs): Layer[] {
const windowStartUtc = new Date(`${windowStartIso}T00:00:00Z`).getTime() / 1000;
const normalized = trips.map((t) => normalize(t, windowStartUtc));
const visible = focusedImei
? normalized.filter((t) => t.imei === focusedImei)
: normalized;
const pathLayer = new PathLayer<NormalizedTrip>({
id: 'paths',
data: visible,
getPath: (d) => d.path_geojson.coordinates as unknown as [number, number][],
getColor: (d) => [...d.color, 90] as [number, number, number, number],
getWidth: 3,
widthUnits: 'pixels',
pickable: false,
});
const tripsLayer = new TripsLayer<NormalizedTrip>({
id: 'trips',
data: visible,
getPath: (d) => d.path_geojson.coordinates as unknown as [number, number][],
getTimestamps: (d) => d.abs_timestamps,
getColor: (d) => d.color,
getWidth: 4,
widthUnits: 'pixels',
capRounded: true,
jointRounded: true,
trailLength,
currentTime,
fadeTrail: true,
pickable: true,
});
return [pathLayer, tripsLayer];
}

33
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,33 @@
import type { Trip, CostCentreCount } from '../types/Trip';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
async function postRpc<T>(rpc: string, body: Record<string, unknown>): Promise<T> {
const res = await fetch(`${API_URL}/rpc/${rpc}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PostgREST ${rpc} ${res.status}: ${text}`);
}
return res.json() as Promise<T>;
}
async function getRpc<T>(rpc: string): Promise<T> {
const res = await fetch(`${API_URL}/rpc/${rpc}`);
if (!res.ok) {
const text = await res.text();
throw new Error(`PostgREST ${rpc} ${res.status}: ${text}`);
}
return res.json() as Promise<T>;
}
export const api = {
listCostCentres: () => getRpc<CostCentreCount[]>('list_cost_centres'),
tripsForDay: (p_date: string, p_cost_centres: string[] | null) =>
postRpc<Trip[]>('trips_for_day', { p_date, p_cost_centres }),
tripsForRange: (p_start: string, p_end: string, p_cost_centres: string[] | null) =>
postRpc<Trip[]>('trips_for_range', { p_start, p_end, p_cost_centres }),
};

36
web/src/lib/color.ts Normal file
View file

@ -0,0 +1,36 @@
// Categorical 12-color palette (deck.gl's "Vivid" applied to dark backgrounds).
// 'Unassigned' always renders as a neutral grey so the gap is visible but quiet.
const PALETTE: [number, number, number][] = [
[91, 141, 239],
[239, 138, 91],
[122, 209, 145],
[231, 99, 152],
[240, 199, 81],
[156, 124, 235],
[80, 200, 200],
[223, 124, 90],
[180, 211, 96],
[220, 124, 195],
[120, 168, 240],
[222, 165, 84],
];
const UNASSIGNED: [number, number, number] = [136, 136, 136];
function hashStr(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function colorFor(costCentre: string): [number, number, number] {
if (costCentre === 'Unassigned') return UNASSIGNED;
return PALETTE[hashStr(costCentre) % PALETTE.length];
}
export function rgbCss([r, g, b]: [number, number, number]): string {
return `rgb(${r}, ${g}, ${b})`;
}

24
web/src/main.tsx Normal file
View file

@ -0,0 +1,24 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View file

@ -0,0 +1,65 @@
import { create } from 'zustand';
import { format, subDays } from 'date-fns';
export type Mode = 'day' | 'range';
export type Speed = 1 | 10 | 60 | 600;
interface VizState {
mode: Mode;
date: string; // ISO yyyy-MM-dd, used in 'day' mode
rangeStart: string;
rangeEnd: string;
costCentres: string[] | null; // null = all
focusedImei: string | null;
currentTime: number; // seconds since 00:00 of the first day in view
playing: boolean;
speed: Speed;
trailLength: number; // seconds of comet tail
setMode: (m: Mode) => void;
setDate: (d: string) => void;
setRange: (start: string, end: string) => void;
setCostCentres: (cc: string[] | null) => void;
toggleCostCentre: (cc: string) => void;
setFocusedImei: (imei: string | null) => void;
setCurrentTime: (t: number) => void;
setPlaying: (p: boolean) => void;
setSpeed: (s: Speed) => void;
setTrailLength: (n: number) => void;
}
const yesterday = format(subDays(new Date(), 1), 'yyyy-MM-dd');
export const useVizStore = create<VizState>((set, get) => ({
mode: 'day',
date: yesterday,
rangeStart: format(subDays(new Date(), 7), 'yyyy-MM-dd'),
rangeEnd: yesterday,
costCentres: null,
focusedImei: null,
currentTime: 0,
playing: false,
speed: 60,
trailLength: 600,
setMode: (m) => set({ mode: m, currentTime: 0, playing: false }),
setDate: (d) => set({ date: d, currentTime: 0 }),
setRange: (rangeStart, rangeEnd) => set({ rangeStart, rangeEnd, currentTime: 0 }),
setCostCentres: (cc) => set({ costCentres: cc }),
toggleCostCentre: (cc) => {
const cur = get().costCentres;
if (cur === null) {
// first toggle: deselect just this one
set({ costCentres: ['__placeholder__'] }); // will be refined by caller with full list
return;
}
set({
costCentres: cur.includes(cc) ? cur.filter((x) => x !== cc) : [...cur, cc],
});
},
setFocusedImei: (imei) => set({ focusedImei: imei }),
setCurrentTime: (t) => set({ currentTime: t }),
setPlaying: (p) => set({ playing: p }),
setSpeed: (s) => set({ speed: s }),
setTrailLength: (n) => set({ trailLength: n }),
}));

22
web/src/types/Trip.ts Normal file
View file

@ -0,0 +1,22 @@
export interface LineStringGeoJSON {
type: 'LineString';
coordinates: [number, number][];
}
export interface Trip {
trip_id: number;
imei: string;
vehicle_name: string | null;
vehicle_number: string | null;
cost_centre: string;
start_time: string;
end_time: string;
distance_km: number | null;
path_geojson: LineStringGeoJSON;
timestamps_rel: number[];
}
export interface CostCentreCount {
cost_centre: string;
vehicle_count: number;
}

10
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_MAPBOX_TOKEN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

22
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

13
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

14
web/vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
preview: {
host: '0.0.0.0',
port: 8080,
},
});