Skip to content

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, and pg_tileserv's internal cache can key on.
  • Native Mapbox clustering on a vector source, removing the MAX_MARKERS = 500 cap the JSON path imposed.
  • Pre-sliced simplification via ST_AsMVTGeom quantisation 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_healthy

It 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 / BreakingTileFilters DTOs and re-encodes them when calling the SQL function. No client value reaches the tile function unless it survived class-validator whitelisting.
  • Company scoperesolveCompanyScope(user) produces a server-side road_ids array 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 by MAX_ZOOM = 22 and the per-zoom max tile index, rejecting nonsense coordinates with 400 instead 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.

FunctionSource migrationEXECUTENotes
tiles.road_sections(z,x,y)(auto-published table source)tileservWhole-table source; pg_tileserv applies ST_AsMVTGeom simplification per zoom.
tiles.accidents_density(z,x,y)1700000000017-CreateTileFunctionstileservUnfiltered 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 onlyFilter-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 onlyOne 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-CreateBreakingTileFunctionstileservUnfiltered braking density.
tiles.breaking_filtered(z,x,y, sections, speed_mins[], speed_maxs[], months[], dows[], hour_from, hour_to, road_ids[])1700000000020gis onlySpeed ranges arrive as paired arrays (max is NULL for 100+).
tiles.breaking_points(z,x,y, …same shape…)1700000000020gis onlyPer-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 onlyPre-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.json lists the public tile sources.
  • curl -o tile.pbf http://localhost:7800/tiles.accidents_density/11/1108/736.pbf returns a non-empty binary; file tile.pbf reports data.
  • DevTools Network panel during map navigation: /tiles/... and /api/tiles/... responses are 200 OK, Content-Type: application/vnd.mapbox-vector-tile, and orders of magnitude smaller than the legacy /api/accidents/map JSON.
  • Scoped user E2E: a logged-in admin assigned to one company sees points only on their roads, even when calling /api/tiles/accidents/.../.pbf directly (decode with @mapbox/vector-tile).