diff --git a/README.md b/README.md index 6b7fdf7..d694fec 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,12 @@ Next.js App Server ├── PostgreSQL + PostGIS (POIs, grid points, precomputed scores) └── Valkey (API response cache, BullMQ queues) +BullMQ Worker (download queue, concurrency 1) + └── download-pbf → streams OSM PBF from Geofabrik (serialised to avoid + redundant parallel downloads; idempotent if file exists) + 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 ├── generate-grid → PostGIS 200 m hex grid → grid_points ├── compute-scores → two-phase orchestrator (see Scoring below) @@ -108,13 +111,73 @@ 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 | +Five categories cover everyday destinations. All categories are scored at the same threshold (5, 10, 15, 20, or 30 minutes — user-selectable). The **universal weight** column shows how strongly a subcategory contributes to its parent category score in the Universal profile (range 0–1); other profiles override specific values — see the Profiles table below. + +### Service & Trade + +| OSM tag(s) | Subcategory | Universal weight | +|---|---|:---:| +| `shop=supermarket`, `shop=wholesale` | `supermarket` | 1.0 | +| `shop=convenience` | `convenience` | 0.65 | +| `amenity=pharmacy`, `shop=pharmacy` | `pharmacy` | 1.0 | +| `amenity=restaurant`, `amenity=fast_food` | `restaurant` | 0.55 | +| `amenity=cafe`, `shop=bakery` | `cafe` | 0.4 | +| `amenity=bank` | `bank` | 0.35 | +| `amenity=post_office` | `post_office` | 0.4 | +| `shop=greengrocer`, `shop=butcher`, `amenity=marketplace`, `shop=department_store`, `shop=mall` | `market` | 0.4 | +| `shop=laundry`, `shop=dry_cleaning` | `laundry` | 0.3 | +| `amenity=atm` | `atm` | 0.2 | + +### Transport + +| OSM tag(s) | Subcategory | Universal weight | +|---|---|:---:| +| `railway=station`, `railway=halt` | `train_station` | 1.0 | +| `railway=subway_entrance`, `railway=subway_station` | `metro` | 1.0 | +| `railway=tram_stop` | `tram_stop` | 0.75 | +| `highway=bus_stop`, `amenity=bus_station` | `bus_stop` | 0.55 | +| `amenity=ferry_terminal` | `ferry` | 0.5 | +| `amenity=bicycle_rental` | `bike_share` | 0.5 | +| `amenity=car_sharing` | `car_share` | 0.5 | + +**Excluded:** `public_transport=stop_position` and `public_transport=platform` — these duplicate `highway=bus_stop` / `railway=tram_stop` nodes at the exact same location and would double-count stops in scoring. + +### Work & School + +| OSM tag(s) | Subcategory | Universal weight | +|---|---|:---:| +| `amenity=kindergarten`, `amenity=childcare` | `kindergarten` | 0.75 | +| `amenity=school` | `school` | 0.7 | +| `office=coworking` | `coworking` | 0.55 | +| `amenity=university`, `amenity=college` | `university` | 0.55 | +| `amenity=driving_school` | `driving_school` | 0.2 | + +**Excluded:** `office=company`, `office=government`, `landuse=commercial`, `landuse=office` — ubiquitous in every urban block; they always win the "nearest POI" race in the detail view, masking meaningful destinations. Government buildings are captured via `amenity=townhall` / `amenity=police` in Culture & Community instead. + +### Culture & Community + +| OSM tag(s) | Subcategory | Universal weight | +|---|---|:---:| +| `amenity=hospital` | `hospital` | 1.0 | +| `amenity=clinic`, `amenity=doctors` | `clinic` | 0.8 | +| `amenity=library` | `library` | 0.7 | +| `amenity=community_centre`, `leisure=arts_centre` | `community_center` | 0.6 | +| `amenity=social_facility` | `social_services` | 0.6 | +| `amenity=theatre`, `amenity=cinema` | `theatre` | 0.5 | +| `tourism=museum` | `museum` | 0.4 | +| `amenity=townhall`, `amenity=police` | `government` | 0.4 | +| `amenity=place_of_worship` | `place_of_worship` | 0.25 | + +### Recreation + +| OSM tag(s) | Subcategory | Universal weight | +|---|---|:---:| +| `leisure=park`, `leisure=garden` | `park` | 1.0 | +| `leisure=playground` | `playground` | 0.85 | +| `leisure=sports_centre`, `leisure=pitch` | `sports_facility` | 0.65 | +| `leisure=fitness_centre` | `gym` | 0.65 | +| `leisure=nature_reserve`, `leisure=golf_course`, `landuse=recreation_ground`, `landuse=grass`, `landuse=meadow`, `landuse=forest` | `green_space` | 0.6 | +| `leisure=swimming_pool`, `amenity=swimming_pool` | `swimming_pool` | 0.55 | ## Scoring @@ -174,13 +237,13 @@ 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 receives in the composite score. - **Subcategory weights** (baked into precomputed scores, range 0–1): how strongly a specific subcategory contributes to its parent category score. -| Profile | Emoji | Category emphasis | Notable subcategory boosts | -|---------|-------|-------------------|---------------------------| -| Universal | ⚖️ | All equal (1.0) | Balanced baseline | -| Young Family | 👨‍👩‍👧 | Work & School 1.5×, Recreation 1.4×, Service 1.2× | school, kindergarten, playground, clinic all → 1.0 | -| Senior | 🧓 | Culture & Community 1.5×, Service 1.4×, Transport 1.1× | hospital, clinic, pharmacy, social services → 1.0; school → 0.05 | -| Young Professional | 💼 | Transport 1.5×, Recreation 1.1× | metro, train → 1.0; gym 0.9; coworking 0.85; school → 0.1 | -| Student | 🎓 | Work & School 1.5×, Transport 1.4×, Culture 1.2× | university, library → 1.0; bike share 0.85; school → 0.05 | +| Profile | Emoji | Category weights | Notable subcategory overrides (vs. Universal) | +|---------|-------|------------------|------------------------------------------------| +| Universal | ⚖️ | All 1.0 | Baseline — see tables above for all weights | +| Young Family | 👨‍👩‍👧 | Work & School 1.5, Recreation 1.4, Service 1.2, Culture 0.9, Transport 1.0 | school → 1.0, kindergarten → 1.0, playground → 1.0, clinic → 1.0, park → 1.0; gym → 0.5, university → 0.2, driving\_school → (inherits 0.2) | +| Senior | 🧓 | Culture & Community 1.5, Service 1.4, Transport 1.1, Recreation 1.0, Work & School 0.3 | hospital → 1.0, clinic → 1.0, pharmacy → 1.0, social\_services → 0.9, bus\_stop → 0.75, tram\_stop → 0.8, metro → 0.8; school → 0.05, kindergarten → 0.05, university → 0.15 | +| Young Professional | 💼 | Transport 1.5, Recreation 1.1, Service 1.0, Culture 0.9, Work & School 0.7 | metro → 1.0, train\_station → 1.0, tram\_stop → 0.85, bike\_share → 0.7; gym → 0.9, restaurant → 0.75, coworking → 0.85; school → 0.1, kindergarten → 0.05 | +| Student | 🎓 | Work & School 1.5, Transport 1.4, Culture & Community 1.2, Service 0.9, Recreation 0.8 | university → 1.0, library → 1.0, coworking → 0.9, bike\_share → 0.85, cafe → 0.9, metro → 1.0, train\_station → 0.9; school → 0.05, kindergarten → 0.05 | ### Composite score diff --git a/infra/osm2pgsql.lua b/infra/osm2pgsql.lua index cccf26c..bdbd7e5 100644 --- a/infra/osm2pgsql.lua +++ b/infra/osm2pgsql.lua @@ -99,6 +99,7 @@ local tag_map = { swimming_pool = { 'recreation', 'swimming_pool' }, garden = { 'recreation', 'park' }, nature_reserve = { 'recreation', 'green_space' }, + golf_course = { 'recreation', 'green_space' }, pitch = { 'recreation', 'sports_facility' }, arts_centre = { 'culture_community', 'community_center' }, }, diff --git a/shared/src/osm-tags.ts b/shared/src/osm-tags.ts index c259dad..16da5a0 100644 --- a/shared/src/osm-tags.ts +++ b/shared/src/osm-tags.ts @@ -22,7 +22,6 @@ export interface CategoryDefinition { id: CategoryId; label: string; defaultWeight: number; - defaultThresholdMinutes: number; color: string; tags: TagFilter[]; } @@ -32,7 +31,6 @@ export const CATEGORIES: CategoryDefinition[] = [ id: "service_trade", label: "Service & Trade", defaultWeight: 0.2, - defaultThresholdMinutes: 10, color: "#e63946", tags: [ { @@ -70,7 +68,6 @@ export const CATEGORIES: CategoryDefinition[] = [ id: "transport", label: "Transport", defaultWeight: 0.2, - defaultThresholdMinutes: 8, color: "#457b9d", tags: [ { key: "highway", values: ["bus_stop"] }, @@ -92,7 +89,6 @@ export const CATEGORIES: CategoryDefinition[] = [ id: "work_school", label: "Work & School", defaultWeight: 0.2, - defaultThresholdMinutes: 20, color: "#2a9d8f", tags: [ { key: "office", values: ["coworking"] }, @@ -113,7 +109,6 @@ export const CATEGORIES: CategoryDefinition[] = [ id: "culture_community", label: "Culture & Community", defaultWeight: 0.2, - defaultThresholdMinutes: 15, color: "#e9c46a", tags: [ { @@ -140,7 +135,6 @@ export const CATEGORIES: CategoryDefinition[] = [ id: "recreation", label: "Recreation", defaultWeight: 0.2, - defaultThresholdMinutes: 10, color: "#06d6a0", tags: [ { diff --git a/worker/src/jobs/compute-transit.ts b/worker/src/jobs/compute-transit.ts index 24b2078..d08695f 100644 --- a/worker/src/jobs/compute-transit.ts +++ b/worker/src/jobs/compute-transit.ts @@ -30,7 +30,7 @@ export type ComputeTransitData = { }; /** Grid points processed per concurrent Valhalla isochrone call. */ -const BATCH_CONCURRENCY = 4; +const BATCH_CONCURRENCY = 8; /** Rows per INSERT. */ const INSERT_CHUNK = 2000;