From da95e90ba2a2f8c13b8fb01927a584018a37b186 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 6 Mar 2026 22:23:03 +0100 Subject: [PATCH] feat: add more personas instead of having only the 'best' score as the main score --- README.md | 13 +++-- apps/web/app/api/location-score/route.ts | 10 ++-- apps/web/app/api/reachable-pois/route.ts | 11 ++-- apps/web/app/page.tsx | 13 +++-- apps/web/components/control-panel.tsx | 50 +++++++++++------- infra/schema.sql | 8 ++- shared/src/osm-tags.ts | 6 +-- worker/src/jobs/compute-scores.ts | 67 ++++++++++++++++++++---- worker/src/jobs/compute-transit.ts | 4 +- worker/src/jobs/refresh-city.ts | 1 + 10 files changed, 129 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c81ee81..b1a0aa3 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,10 @@ Travel times are obtained from [Valhalla](https://github.com/valhalla/valhalla), - **Walking, cycling, driving** — Valhalla's `sources_to_targets` matrix endpoint. For each grid point the 6 spatially nearest POIs in the category are sent as targets; the resulting travel-time matrix gives the exact routed time to each. The nearest POI *per subcategory* is retained. - **Transit** — Valhalla's matrix endpoint does not support transit. Instead, a multimodal isochrone is computed per grid point at contour intervals of 5, 10, 15, 20, and 30 minutes (fixed departure: next Tuesday 08:00 for reproducible GTFS results). PostGIS `ST_Within` then classifies every POI in the city into the smallest contour it falls within, giving estimated times of 300 / 600 / 900 / 1200 / 1800 seconds. Grid points outside the transit network are silently skipped — they receive no transit score. -- **Best mode (`fifteen`)** — a synthetic mode computed during score aggregation: for each (grid point, subcategory) the minimum travel time across walking, cycling, and transit is used. Driving is excluded intentionally. No extra routing calls are needed. +- **Cyclist (`cyclist`)** — synthetic persona: `MIN(walking, cycling, transit)` per POI. Represents someone who cycles and also uses transit when faster. No extra routing calls needed. +- **Cyclist, no transit (`cycling_walk`)** — synthetic persona: `MIN(walking, cycling)`. Represents someone who cycles but avoids public transit. No extra routing calls needed. +- **Transit + Walk (`transit_walk`)** — synthetic persona: `MIN(walking, transit)`. Represents someone who does not cycle. No extra routing calls needed. +- **Walker** — the raw `walking` mode also serves as a fourth persona: someone who only walks. ### Scoring formula @@ -138,10 +141,10 @@ All scores are precomputed at ingest time for every combination of threshold (5 Each subcategory *i* contributes a proximity score based on travel time `t` and threshold `T` (both in seconds) using exponential decay: ``` -score(t, T) = exp(−6 × t / T) +score(t, T) = exp(−3 × t / T) ``` -At t = 0 the score is 1.0. At the threshold it is exp(−6) ≈ 0.002 — essentially zero. Close proximity strongly dominates: a sixth of the threshold away scores ~0.37, a third scores ~0.14. This ensures that only genuinely nearby POIs contribute meaningfully to the score. +At t = 0 the score is 1.0. At the threshold it is exp(−3) ≈ 0.05 — a POI reachable in exactly the threshold time barely contributes. Close proximity dominates: a third of the threshold away scores ~0.37, halfway scores ~0.22. This ensures that genuinely nearby POIs are rated much more highly than merely reachable ones. The category score aggregates across subcategories **and** across multiple nearby POIs of the same subcategory via a **complement product** weighted by profile-specific importance weights `w_i ∈ [0, 1]`: @@ -257,7 +260,9 @@ A single SQL CTE chain inside PostgreSQL computes all scores without streaming d | Mode | Key | Source | |------|-----|--------| -| Best mode | `fifteen` | Synthetic — `MIN(travel_time_s)` across walking, cycling, transit per subcategory during Phase 2. No extra routing calls. | +| Cyclist | `cyclist` | Synthetic — `MIN(walking, cycling, transit)` per POI. Persona: cycles + uses transit. | +| Cyclist, no transit | `cycling_walk` | Synthetic — `MIN(walking, cycling)` per POI. Persona: cycles, avoids transit. | +| Transit + Walk | `transit_walk` | Synthetic — `MIN(walking, transit)` per POI. Persona: does not cycle. | | Walking | `walking` | Valhalla pedestrian matrix, exact seconds | | Cycling | `cycling` | Valhalla bicycle matrix, exact seconds | | Transit | `transit` | Valhalla multimodal isochrone, quantised to 5-min bands (requires GTFS feed) | diff --git a/apps/web/app/api/location-score/route.ts b/apps/web/app/api/location-score/route.ts index 5e64e59..c9d90f7 100644 --- a/apps/web/app/api/location-score/route.ts +++ b/apps/web/app/api/location-score/route.ts @@ -122,10 +122,12 @@ export async function GET(req: NextRequest) { recreation: r.time_recreation ?? undefined, }; - // "fifteen" is synthetic — grid_poi_details only stores the raw modes - // (walking, cycling, transit, driving). For fifteen we query all three - // contributing modes and pick the best (minimum travel_time_s) per subcategory. - const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode]; + // Synthetic modes query multiple raw modes and pick the best per subcategory. + const detailModes = + mode === "cyclist" ? ["walking", "cycling", "transit"] : + mode === "cycling_walk" ? ["walking", "cycling"] : + mode === "transit_walk" ? ["walking", "transit"] : + [mode]; // Fetch nearest POI per subcategory for this grid point and mode. const detailRows = await Promise.resolve(sql<{ diff --git a/apps/web/app/api/reachable-pois/route.ts b/apps/web/app/api/reachable-pois/route.ts index ec12c52..1477e09 100644 --- a/apps/web/app/api/reachable-pois/route.ts +++ b/apps/web/app/api/reachable-pois/route.ts @@ -21,10 +21,13 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 }); } - // Map modes the same way the display isochrone route does. - // "fifteen" uses cycling as the representative isochrone mode. - // "transit" uses its own multimodal isochrone. - const isoMode = mode === "fifteen" ? "cycling" : mode; + // Synthetic modes map to a single isochrone mode for the spatial POI lookup. + // "cyclist" uses cycling (largest catchment); "transit_walk" uses transit. + const isoMode = + mode === "cyclist" ? "cycling" : + mode === "cycling_walk" ? "cycling" : + mode === "transit_walk" ? "transit" : + mode; // Use the identical contours array that the display isochrone route caches with, // so the exact-match lookup is guaranteed to find the entry once isochroneData diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index ff8158f..8b419da 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -31,7 +31,7 @@ export default function HomePage() { const [cities, setCities] = useState([]); const [selectedCity, setSelectedCity] = useState(null); const [profile, setProfile] = useState("universal"); - const [mode, setMode] = useState("fifteen"); + const [mode, setMode] = useState("cyclist"); const [threshold, setThreshold] = useState(15); const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights }); const [activeCategory, setActiveCategory] = useState("composite"); @@ -218,10 +218,13 @@ export default function HomePage() { body: JSON.stringify({ lng: pinLocation.lng, lat: pinLocation.lat, - // "fifteen" uses cycling as the representative display mode (largest - // reliable non-time-dependent coverage). "transit" uses the real - // multimodal costing supported by fetchIsochrone. - travelMode: mode === "fifteen" ? "cycling" : mode, + // Synthetic modes map to a single Valhalla costing for the isochrone display. + // "cyclist" uses cycling (largest catchment); "transit_walk" uses transit. + travelMode: + mode === "cyclist" ? "cycling" : + mode === "cycling_walk" ? "cycling" : + mode === "transit_walk" ? "transit" : + mode, contourMinutes: isochroneContours(threshold), }), }) diff --git a/apps/web/components/control-panel.tsx b/apps/web/components/control-panel.tsx index d2a6bbf..fcce254 100644 --- a/apps/web/components/control-panel.tsx +++ b/apps/web/components/control-panel.tsx @@ -3,13 +3,15 @@ import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared"; import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared"; -const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> = +const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string; description: string }> = [ - { value: "fifteen", label: "Best mode", icon: "🏆" }, - { value: "walking", label: "Walking", icon: "🚶" }, - { value: "cycling", label: "Cycling", icon: "🚲" }, - { value: "transit", label: "Transit", icon: "🚌" }, - { value: "driving", label: "Driving", icon: "🚗" }, + { value: "cyclist", label: "Cyclist", icon: "🚶🚲🚌", description: "Best of walking, cycling & transit — for people who cycle and use public transport" }, + { value: "cycling_walk", label: "Cyclist (no transit)", icon: "🚶🚲", description: "Best of walking & cycling only — for cyclists who avoid public transport" }, + { value: "transit_walk", label: "Transit + Walk", icon: "🚶🚌", description: "Best of walking & transit — for people without a bike" }, + { value: "walking", label: "Walker", icon: "🚶", description: "Walking only — for people who rely solely on foot travel" }, + { value: "cycling", label: "Cycling only", icon: "🚲", description: "Raw cycling travel times, unblended" }, + { value: "transit", label: "Transit only", icon: "🚌", description: "Raw transit travel times, unblended" }, + { value: "driving", label: "Driving only", icon: "🚗", description: "Raw driving travel times, unblended" }, ]; @@ -92,20 +94,28 @@ export function ControlPanel({ Travel Mode

- {TRAVEL_MODES.map((m) => ( - - ))} + {TRAVEL_MODES.map((m, i) => { + const isRightCol = i % 2 === 1; + return ( +
+ +
+ {m.description} +
+
+
+ ); + })}
diff --git a/infra/schema.sql b/infra/schema.sql index 4c3dbf6..a37dcc8 100644 --- a/infra/schema.sql +++ b/infra/schema.sql @@ -24,6 +24,12 @@ ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAUL ALTER TABLE cities ADD COLUMN IF NOT EXISTS boundary geometry(MultiPolygon, 4326); ALTER TABLE cities ADD COLUMN IF NOT EXISTS refresh_iter SMALLINT NOT NULL DEFAULT 0; +-- Migration: rename 'fifteen' mode to 'cyclist' and update CHECK constraint +UPDATE grid_scores SET travel_mode = 'cyclist' WHERE travel_mode = 'fifteen'; +ALTER TABLE grid_scores DROP CONSTRAINT IF EXISTS grid_scores_travel_mode_check; +ALTER TABLE grid_scores ADD CONSTRAINT grid_scores_travel_mode_check + CHECK (travel_mode IN ('walking','cycling','driving','transit','cyclist','cycling_walk','transit_walk')); + CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox); CREATE INDEX IF NOT EXISTS idx_cities_boundary ON cities USING GIST (boundary); @@ -78,7 +84,7 @@ CREATE INDEX IF NOT EXISTS idx_grid_hidden_gem CREATE TABLE IF NOT EXISTS grid_scores ( grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE, category TEXT NOT NULL, - travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving','transit','fifteen')), + travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving','transit','cyclist','cycling_walk','transit_walk')), threshold_min INTEGER NOT NULL, profile TEXT NOT NULL DEFAULT 'universal', nearest_poi_id BIGINT, diff --git a/shared/src/osm-tags.ts b/shared/src/osm-tags.ts index 16da5a0..c003576 100644 --- a/shared/src/osm-tags.ts +++ b/shared/src/osm-tags.ts @@ -7,10 +7,10 @@ export type CategoryId = /** Modes that produce real routing data (matrix or isochrone calls). */ export type RoutingMode = "walking" | "cycling" | "driving" | "transit"; -/** All display modes, including the synthetic "fifteen" (best-of walking+cycling+transit). */ -export type TravelMode = RoutingMode | "fifteen"; +/** All display modes, including synthetic best-of modes per persona. */ +export type TravelMode = RoutingMode | "cyclist" | "cycling_walk" | "transit_walk"; -export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "fifteen"] as const satisfies TravelMode[]; +export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "cyclist", "cycling_walk", "transit_walk"] as const satisfies TravelMode[]; export const VALID_THRESHOLDS = [5, 10, 15, 20, 30] as const; export interface TagFilter { diff --git a/worker/src/jobs/compute-scores.ts b/worker/src/jobs/compute-scores.ts index 02a1187..ddce4f8 100644 --- a/worker/src/jobs/compute-scores.ts +++ b/worker/src/jobs/compute-scores.ts @@ -180,22 +180,44 @@ export async function handleComputeScores( JOIN grid_points gp ON gp.id = gpd.grid_point_id WHERE gp.city_slug = ${citySlug} ), - fifteen_subcat AS ( - -- "fifteen" mode: for each unique POI, take the best time across - -- walking / cycling / transit so each POI contributes independently - -- to the complement-product formula (preserving diversity). + cyclist_subcat AS ( + -- "cyclist" persona: best time per POI across walking / cycling / transit SELECT grid_point_id, category, subcategory, - 'fifteen'::text AS travel_mode, + 'cyclist'::text AS travel_mode, MIN(travel_time_s) AS travel_time_s FROM base WHERE travel_mode IN ('walking', 'cycling', 'transit') GROUP BY grid_point_id, category, subcategory, nearest_poi_id ), + cycling_walk_subcat AS ( + -- "cycling_walk" persona: best time per POI across walking / cycling only (no transit) + SELECT + grid_point_id, category, subcategory, + 'cycling_walk'::text AS travel_mode, + MIN(travel_time_s) AS travel_time_s + FROM base + WHERE travel_mode IN ('walking', 'cycling') + GROUP BY grid_point_id, category, subcategory, nearest_poi_id + ), + transit_walk_subcat AS ( + -- "transit_walk" persona: best time per POI across walking / transit only + SELECT + grid_point_id, category, subcategory, + 'transit_walk'::text AS travel_mode, + MIN(travel_time_s) AS travel_time_s + FROM base + WHERE travel_mode IN ('walking', 'transit') + GROUP BY grid_point_id, category, subcategory, nearest_poi_id + ), all_subcat AS ( SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM base UNION ALL - SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM fifteen_subcat + SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM cyclist_subcat + UNION ALL + SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM cycling_walk_subcat + UNION ALL + SELECT grid_point_id, category, subcategory, travel_mode, travel_time_s FROM transit_walk_subcat ), road_nearest AS ( -- Nearest POI per (grid_point, category, mode) by distance @@ -205,21 +227,44 @@ export async function handleComputeScores( WHERE nearest_poi_id IS NOT NULL ORDER BY grid_point_id, category, travel_mode, distance_m ), - fifteen_nearest AS ( - -- Nearest POI for "fifteen": closest across walking / cycling / transit + cyclist_nearest AS ( SELECT DISTINCT ON (grid_point_id, category) grid_point_id, category, - 'fifteen'::text AS travel_mode, + 'cyclist'::text AS travel_mode, nearest_poi_id, distance_m, travel_time_s FROM base WHERE travel_mode IN ('walking', 'cycling', 'transit') AND nearest_poi_id IS NOT NULL ORDER BY grid_point_id, category, distance_m ), + cycling_walk_nearest AS ( + SELECT DISTINCT ON (grid_point_id, category) + grid_point_id, category, + 'cycling_walk'::text AS travel_mode, + nearest_poi_id, distance_m, travel_time_s + FROM base + WHERE travel_mode IN ('walking', 'cycling') + AND nearest_poi_id IS NOT NULL + ORDER BY grid_point_id, category, distance_m + ), + transit_walk_nearest AS ( + SELECT DISTINCT ON (grid_point_id, category) + grid_point_id, category, + 'transit_walk'::text AS travel_mode, + nearest_poi_id, distance_m, travel_time_s + FROM base + WHERE travel_mode IN ('walking', 'transit') + AND nearest_poi_id IS NOT NULL + ORDER BY grid_point_id, category, distance_m + ), all_nearest AS ( SELECT * FROM road_nearest UNION ALL - SELECT * FROM fifteen_nearest + SELECT * FROM cyclist_nearest + UNION ALL + SELECT * FROM cycling_walk_nearest + UNION ALL + SELECT * FROM transit_walk_nearest ), profile_weights AS ( SELECT profile_id, subcategory, weight @@ -248,7 +293,7 @@ export async function handleComputeScores( 1.0 - COALESCE(pw.weight, ${DEFAULT_SUBCATEGORY_WEIGHT}::float8) * CASE WHEN s.travel_time_s IS NULL THEN 0.0 - ELSE EXP(-6.0 * s.travel_time_s / (t.threshold_min * 60.0)) + ELSE EXP(-3.0 * s.travel_time_s / (t.threshold_min * 60.0)) END, 1e-10 )) diff --git a/worker/src/jobs/compute-transit.ts b/worker/src/jobs/compute-transit.ts index 56c7349..7771500 100644 --- a/worker/src/jobs/compute-transit.ts +++ b/worker/src/jobs/compute-transit.ts @@ -12,8 +12,8 @@ * * If transit routing fails for a grid point (e.g. no GTFS data, or the point is * outside the transit network), it is silently skipped — transit contributes - * nothing to that grid point's "fifteen" score, which then falls back to - * walking/cycling only. + * nothing to that grid point's "cyclist" / "transit_walk" scores, which then + * fall back to walking/cycling only. * * Transit scores are computed ONCE per city (not per category) since the isochrone * polygon covers all categories in a single PostGIS spatial join. diff --git a/worker/src/jobs/refresh-city.ts b/worker/src/jobs/refresh-city.ts index 1ea10da..0650911 100644 --- a/worker/src/jobs/refresh-city.ts +++ b/worker/src/jobs/refresh-city.ts @@ -10,6 +10,7 @@ export type RefreshCityData = { citySlug: string; geofabrikUrl: string; resolutionM?: number; + iter: number; /** Set after flow.add() — the ID of the enqueued compute-scores job. */ computeScoresJobId?: string; };