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