No description
Find a file
2026-03-02 23:44:10 +01:00
apps/web fix: include mapbox css 2026-03-02 23:44:10 +01:00
infra feat: add hidden gem mode and some fixes 2026-03-02 18:46:59 +01:00
shared feat: add hidden gem mode and some fixes 2026-03-02 18:46:59 +01:00
worker feat: add hidden gem mode and some fixes 2026-03-02 18:46:59 +01:00
.env.example initial commit 2026-03-01 21:59:44 +01:00
.gitignore initial commit 2026-03-01 21:59:44 +01:00
docker-compose.yml initial commit 2026-03-01 21:59:44 +01:00
Dockerfile initial commit 2026-03-01 21:59:44 +01:00
package-lock.json initial commit 2026-03-01 21:59:44 +01:00
package.json initial commit 2026-03-01 21:59:44 +01:00
README.md doc: update readme 2026-03-01 22:19:19 +01:00
tsconfig.base.json initial commit 2026-03-01 21:59:44 +01:00

Transportationer — 15-Minute City Analyzer

A web application for analyzing urban accessibility through the lens of the 15-minute city concept. Shows a heatmap indicating distance to locations of interest across 5 categories: Service & Trade, Transport, Work & School, Culture & Community, and Recreation.

Architecture

Browser (Next.js / React)
  ├── MapLibre GL JS (map + canvas heatmap / isochrone overlay)
  └── API calls → Next.js API routes

Next.js App Server
  ├── Public API: /api/cities /api/tiles /api/stats /api/location-score /api/isochrones
  ├── Admin API: /api/admin/** (auth-protected)
  ├── PostgreSQL + PostGIS (POIs, grid points, precomputed scores)
  └── Valkey (API response cache, BullMQ queues)

BullMQ Worker (pipeline queue, concurrency 8)
  ├── refresh-city     → orchestrates full ingest via FlowProducer
  ├── download-pbf     → streams OSM PBF from Geofabrik
  ├── extract-pois     → osmium filter + osm2pgsql flex → raw_pois
  ├── build-valhalla   → clips PBF, builds Valhalla routing tiles
  ├── generate-grid    → PostGIS 200 m hex grid → grid_points
  ├── compute-scores   → two-phase orchestrator (see Scoring below)
  └── compute-routing  → Valhalla matrix → grid_poi_details
                         (15 parallel jobs: 3 modes × 5 categories)

BullMQ Worker (valhalla queue, concurrency 1)
  └── build-valhalla   → runs valhalla_build_tiles, manages valhalla_service

Valhalla (child process of valhalla worker)
  ├── sources_to_targets matrix → compute-routing jobs
  └── isochrones endpoint       → user click → /api/isochrones

Protomaps → self-hosted map tiles (PMTiles)

Quick Start

1. Configure environment

cp .env.example .env
# Edit .env with strong passwords

# Generate admin password hash
node -e "require('bcryptjs').hash('yourpassword', 12).then(console.log)"
# Paste result as ADMIN_PASSWORD_HASH in .env

2. Start services

docker compose up -d

3. Add a city

Open http://localhost:3000/admin, log in, click Add City, browse Geofabrik regions (e.g. europe/germany/berlin), and start ingestion. Progress is shown live.

Processing time:

  • Small city (< 100k pop): ~515 minutes
  • Large city (1M+ pop): ~3090 minutes

4. Explore

Open http://localhost:3000 and select your city.

Map Tiles

By default the app uses CartoDB Positron (CDN). For fully offline operation, download a PMTiles file for your region:

# Example: download Berlin region tiles
wget https://maps.protomaps.com/builds/berlin.pmtiles -O apps/web/public/tiles/region.pmtiles
# Then switch to the PMTiles style:
cp apps/web/public/tiles/style.pmtiles.json apps/web/public/tiles/style.json

Development

npm install
npm run dev               # Next.js dev server on :3000
npm run worker:dev        # BullMQ worker with hot reload

Required local services: PostgreSQL+PostGIS, Valkey. Easiest via:

docker compose up postgres valkey -d

Category Definitions

Category OSM Sources
Service & Trade supermarkets, shops, restaurants, pharmacies, banks, cafés
Transport bus stops, metro, tram, train stations, bike share, car share
Work & School offices, coworking, schools, kindergartens, universities
Culture & Community libraries, hospitals, clinics, museums, theatres, community centres
Recreation parks, playgrounds, sports centres, gyms, green spaces

Scoring

Data pipeline

For each grid point (200 m hexagonal spacing) the pipeline runs in two phases:

Phase 1 — Routing (15 parallel jobs: 3 modes × 5 categories)

A PostGIS KNN lateral join (<-> operator) finds the 6 nearest POIs in the category for each grid point. Those POI coordinates are passed to Valhalla's sources_to_targets matrix API to obtain real network travel times for the requested travel mode (walking, cycling, driving). The nearest POI per subcategory is persisted to grid_poi_details.

Phase 2 — Score aggregation

Scores are precomputed for every combination of:

  • 5 thresholds: 5, 10, 15, 20, 30 minutes
  • 3 travel modes: walking, cycling, driving
  • 5 profiles: Universal, Young Family, Senior, Young Professional, Student

Scoring formula

Each subcategory i within a category contributes a sigmoid score:

sigmoid(t, T) = 1 / (1 + exp(4 × (t  T) / T))

Where t is the Valhalla travel time in seconds and T is the threshold in seconds. The sigmoid equals 0.5 exactly at the threshold and approaches 1 for very short times.

The category score combines subcategories via a complement-product, weighted by per-profile subcategory importance weights w_i ∈ [0, 1]:

category_score = 1  ∏ (1  w_i × sigmoid(t_i, T))

This captures diversity of coverage: reaching one supermarket near you already yields a high score, but having a pharmacy, bakery, and bank nearby as well pushes the score higher.

Profiles

Each profile carries two sets of weights:

  • Category weights (used as slider presets in the UI, range 02): how much relative importance each of the 5 categories gets in the composite score.
  • Subcategory weights (used during score computation, range 01): how much a specific subcategory contributes to its category score.
Profile Focus
Universal Balanced across all resident types
Young Family Schools, playgrounds, healthcare, daily shopping
Senior Healthcare, local services, accessible green space, transit
Young Professional Rapid transit, fitness, dining, coworking
Student University, library, cafés, transit, budget services

Composite score

The composite shown on the heatmap is a weighted average of the 5 category scores. Category weights come from the selected profile but can be adjusted freely in the UI. All scores are precomputed — changing the profile or weights only triggers a client-side re-blend with no server round-trip.