# 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 ```bash 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 ```bash docker compose up -d ``` ### 3. Add a city Open [http://localhost:3000/admin](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](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: ```bash # 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 ```bash 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: ```bash 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.