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:
parent
d5865bbe28
commit
47c86c9d7a
37 changed files with 4477 additions and 1 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
compose/.env
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/.env
|
||||
web/.env.local
|
||||
web/*.tsbuildinfo
|
||||
.DS_Store
|
||||
db_forgejo.txt
|
||||
84
README.md
84
README.md
|
|
@ -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
22
compose/.env.example
Normal 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
|
||||
37
compose/docker-compose.yaml
Normal file
37
compose/docker-compose.yaml
Normal 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.
|
||||
16
db/migrations/001_viz_anon_role.sql
Normal file
16
db/migrations/001_viz_anon_role.sql
Normal 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;
|
||||
25
db/migrations/002_trips_viz_view.sql
Normal file
25
db/migrations/002_trips_viz_view.sql
Normal 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;
|
||||
62
db/migrations/003_trips_for_day_rpc.sql
Normal file
62
db/migrations/003_trips_for_day_rpc.sql
Normal 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;
|
||||
65
db/migrations/004_trips_for_range_rpc.sql
Normal file
65
db/migrations/004_trips_for_range_rpc.sql
Normal 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;
|
||||
22
db/migrations/005_list_cost_centres_rpc.sql
Normal file
22
db/migrations/005_list_cost_centres_rpc.sql
Normal 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
353
trips_deckgl_tracksolid.md
Normal 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 — 0–86400s 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 50–150, 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:00–24: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
4
web/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
3
web/.env.example
Normal file
3
web/.env.example
Normal 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
8
web/Caddyfile
Normal 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
16
web/Dockerfile
Normal 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
12
web/index.html
Normal 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
33
web/package.json
Normal 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
2763
web/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
50
web/src/App.tsx
Normal file
50
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
web/src/components/MapView.tsx
Normal file
50
web/src/components/MapView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
web/src/components/Sidebar/CostCentreFilter.tsx
Normal file
67
web/src/components/Sidebar/CostCentreFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
web/src/components/Sidebar/DateControls.tsx
Normal file
47
web/src/components/Sidebar/DateControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
web/src/components/Sidebar/VehicleDrilldown.tsx
Normal file
63
web/src/components/Sidebar/VehicleDrilldown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
web/src/components/Timebar/TimeSlider.tsx
Normal file
61
web/src/components/Timebar/TimeSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
web/src/hooks/useAnimationLoop.ts
Normal file
30
web/src/hooks/useAnimationLoop.ts
Normal 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
24
web/src/hooks/useTrips.ts
Normal 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
230
web/src/index.css
Normal 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;
|
||||
}
|
||||
77
web/src/layers/buildLayers.ts
Normal file
77
web/src/layers/buildLayers.ts
Normal 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
33
web/src/lib/api.ts
Normal 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
36
web/src/lib/color.ts
Normal 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
24
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
65
web/src/store/useVizStore.ts
Normal file
65
web/src/store/useVizStore.ts
Normal 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
22
web/src/types/Trip.ts
Normal 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
10
web/src/vite-env.d.ts
vendored
Normal 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
22
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
13
web/tsconfig.node.json
Normal file
13
web/tsconfig.node.json
Normal 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
14
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue