Appearance
Tile Serving
The map renders large geometry layers (per-segment density, per-event markers, road sections) as Mapbox Vector Tiles (MVT) rather than fetching JSON GeoJSON for every viewport change. Tiles are produced by PostGIS functions in the tiles schema and served by pg_tileserv — except where filters or company scope make publishing the SQL function unsafe, in which case a thin NestJS proxy at /api/tiles/* calls the function as the application user.
Why MVT
The legacy path (/api/accidents/map, /api/road-sections/geometries) re-serialised the same ST_AsGeoJSON(...)::json payloads on every request, ran ST_SimplifyPreserveTopology per request for roads, and used a custom degree-aligned tile grid that no HTTP cache could key on. MVT gives:
- 5–10× smaller wire payloads than equivalent GeoJSON.
- Stable
{z}/{x}/{y}URLs that browsers, CDNs, andpg_tileserv's internal cache can key on. - Native Mapbox clustering on a vector source, removing the
MAX_MARKERS = 500cap the JSON path imposed. - Pre-sliced simplification via
ST_AsMVTGeomquantisation to 4096 units — no per-zoom manual tolerances.
Background and rollout context: PG_TILESERV_PLAN.md at the repo root.
Components
Browser
│
│ GET /tiles/tiles.accidents_density/{z}/{x}/{y}.pbf (anonymous, unfiltered)
│ GET /tiles/tiles.road_sections/{z}/{x}/{y}.pbf (anonymous)
│ GET /tiles/tiles.breaking_density/{z}/{x}/{y}.pbf (anonymous, unfiltered)
▼
nginx (frontend) ── proxies /tiles/* ─────────► pg_tileserv
│ (port 7800)
│ │
│ GET /api/tiles/accidents/{z}/{x}/{y}.pbf?reason=... │
│ GET /api/tiles/accidents/points/{z}/{x}/{y}.pbf?... │
│ GET /api/tiles/breaking/{z}/{x}/{y}.pbf?... │
│ GET /api/tiles/breaking/points/{z}/{x}/{y}.pbf?... │
▼ │
NestJS API (TilesController, TilesService) │
• resolveCompanyScope(user) → road_ids │
• validates (z, x, y), DTO-whitelists filters │
• SELECT tiles.accidents_filtered($1, …) as the gis user │
▼ ▼
PostgreSQL + PostGIS
schema "tiles"
├── accidents_density(z,x,y) ◄── pg_tileserv calls
├── road_sections(z,x,y) ◄── pg_tileserv calls
├── breaking_density(z,x,y) ◄── pg_tileserv calls
├── accidents_filtered(z,x,y, …, road_ids) (no EXECUTE to tileserv)
├── accidents_points(z,x,y, …, road_ids) (no EXECUTE to tileserv)
├── breaking_filtered(z,x,y, …, road_ids) (no EXECUTE to tileserv)
└── breaking_points(z,x,y, …, road_ids) (no EXECUTE to tileserv)pg_tileserv Docker service
Defined in docker-compose.yml:
yaml
tileserv:
image: pramsey/pg_tileserv:latest
ports: ["7800:7800"]
environment:
DATABASE_URL: "postgresql://tileserv:${TILESERV_DB_PASSWORD:-tileserv_secret}@postgres:5432/gis_data?sslmode=disable"
TS_BASEPATH: "/tiles"
TS_CORSORIGINS: "*"
depends_on:
postgres:
condition: service_healthyIt connects as the tileserv Postgres role (created in migration 1700000000017-CreateTileFunctions) which has SELECT on analytics.subsegments, analytics.density_by_segment, analytics.breaking_by_segment, and breaking — and EXECUTE only on the public-safe tiles.accidents_density, tiles.road_sections, tiles.breaking_density functions. Any object not granted to this role is invisible to the introspection layer; users, audit log, raw data, etc. can never be exposed by accident.
Frontend nginx routing
frontend/nginx.conf.template proxies two prefixes — /api/ to the NestJS service, and /tiles/ to pg_tileserv (the production target is http://pgtileserv.railway.internal:7800; locally, the Vite dev server proxies to TILESERV_URL, default http://localhost:7800). All map requests therefore appear same-origin to the browser, so the JWT cookie keeps working unchanged.
NestJS TilesController (api/src/tiles/)
The controller handles the cases pg_tileserv deliberately cannot:
- Filter-aware tiles — clients pass query-string filters; the controller validates them via
AccidentFiltersQuery/BreakingTileFiltersDTOs and re-encodes them when calling the SQL function. No client value reaches the tile function unless it survivedclass-validatorwhitelisting. - Company scope —
resolveCompanyScope(user)produces a server-sideroad_idsarray that is passed as the function's last argument. The scope is never sourced from the URL. If a scoped user has zero roads, the controller short-circuits to an empty MVT before even hitting Postgres. ParseIntPipe+assertValidTile—(z, x, y)are bounded byMAX_ZOOM = 22and the per-zoom max tile index, rejecting nonsense coordinates with400instead of running the SQL.
Both density and points handlers respond with Content-Type: application/vnd.mapbox-vector-tile and Cache-Control: private, max-age=300 so browsers can cache, but shared proxies/CDNs cannot.
TilesService calls the SQL functions as the application user (gis), not as tileserv — this is what enforces the no-EXECUTE-to-tileserv contract on the filtered/points functions.
Tile functions
All seven functions live in the tiles schema and are created as STABLE PARALLEL SAFE SQL functions returning bytea. The zoom → segment-size mapping mirrors shared/src/config/segments.config.ts (250 / 500 / 1000 / 2000 / 4000 m), so client-visible behaviour matches the legacy JSON path exactly.
| Function | Source migration | EXECUTE | Notes |
|---|---|---|---|
tiles.road_sections(z,x,y) | (auto-published table source) | tileserv | Whole-table source; pg_tileserv applies ST_AsMVTGeom simplification per zoom. |
tiles.accidents_density(z,x,y) | 1700000000017-CreateTileFunctions | tileserv | Unfiltered density from analytics.density_by_segment. |
tiles.accidents_filtered(z,x,y, reason, type, classification, start_date, end_date, road_ids[], participant_type, participant_sex, participant_injury, age_min, age_max, alcohol_involved) | 1700000000018 (base) + 1700000000024-AccidentsFilteredParticipants (participants) | gis only | Filter-aware density. CSV main-axis filters split inside SQL with string_to_array; participant args drive an EXISTS join against accident_participants. |
tiles.accidents_points(z,x,y, …, sections, …, road_ids[], participant_type, participant_sex, participant_injury, age_min, age_max, alcohol_involved) | 1700000000019 (base) + 1700000000022-AccidentsPointsSeverity (classification + participants) | gis only | One MVT point per accident at zoom ≥ 16. Emits accidentclassification + accidentreason so the frontend can paint markers by severity without a detail fetch. |
tiles.breaking_density(z,x,y) | 1700000000020-CreateBreakingTileFunctions | tileserv | Unfiltered braking density. |
tiles.breaking_filtered(z,x,y, sections, speed_mins[], speed_maxs[], months[], dows[], hour_from, hour_to, road_ids[]) | 1700000000020 | gis only | Speed ranges arrive as paired arrays (max is NULL for 100+). |
tiles.breaking_points(z,x,y, …same shape…) | 1700000000020 | gis only | Per-event markers. |
tiles.speed_lines(z,x,y, month_from, month_to, min_pct85, max_pct85, min_average, max_average, min_pct15, max_pct15) | 1700000000023-CreateSpeedTileFunctions (base) + 1700000000025-SpeedLinesPct15Filter (pct15) | gis only | Pre-aggregates AVG(pct85), AVG(avg), AVG(pct15), MIN(min) per NIRA segment across the month window. All three bounds are post-aggregate filters. The frontend sub-layer toggle picks which emitted property (speedPct85 / speedAvg / speedPct15) drives the paint expression. |
Migrations 18, 19, 20, 22, 24, and 25 deliberately do not grant EXECUTE on the filtered/points/speed functions to the tileserv role — those are reachable only through TilesService, which controls road_ids server-side.
Frontend integration
The map composables build vector sources whose URLs are flipped between the public /tiles/... (no filters, no scope) and the proxied /api/tiles/... (filters or scoped user) at runtime:
ts
map.addSource('accidents-density', {
type: 'vector',
tiles: [hasFilters || isScoped
? `/api/tiles/accidents/{z}/{x}/{y}.pbf?${qs}`
: '/tiles/tiles.accidents_density/{z}/{x}/{y}.pbf'],
});Mapbox invalidates its tile cache automatically when setTiles([...]) is called with new URLs, so changing a filter immediately re-fetches.
Verification
curl -I http://localhost:7800/index.jsonlists the public tile sources.curl -o tile.pbf http://localhost:7800/tiles.accidents_density/11/1108/736.pbfreturns a non-empty binary;file tile.pbfreportsdata.- DevTools Network panel during map navigation:
/tiles/...and/api/tiles/...responses are200 OK,Content-Type: application/vnd.mapbox-vector-tile, and orders of magnitude smaller than the legacy/api/accidents/mapJSON. - Scoped user E2E: a logged-in admin assigned to one company sees points only on their roads, even when calling
/api/tiles/accidents/.../.pbfdirectly (decode with@mapbox/vector-tile).