| apps/web | ||
| infra | ||
| shared | ||
| worker | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
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): ~5–15 minutes
- Large city (1M+ pop): ~30–90 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 0–2): how much relative importance each of the 5 categories gets in the composite score.
- Subcategory weights (used during score computation, range 0–1): 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.