feat: add more personas instead of having only the 'best' score as the main score

This commit is contained in:
Jan-Henrik 2026-03-06 22:23:03 +01:00
parent 9158ed3ba2
commit da95e90ba2
10 changed files with 129 additions and 54 deletions

View file

@ -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) |

View file

@ -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<{

View file

@ -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

View file

@ -31,7 +31,7 @@ export default function HomePage() {
const [cities, setCities] = useState<City[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [profile, setProfile] = useState<ProfileId>("universal");
const [mode, setMode] = useState<TravelMode>("fifteen");
const [mode, setMode] = useState<TravelMode>("cyclist");
const [threshold, setThreshold] = useState(15);
const [weights, setWeights] = useState({ ...PROFILES["universal"].categoryWeights });
const [activeCategory, setActiveCategory] = useState<CategoryId | "composite">("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),
}),
})

View file

@ -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,11 +94,13 @@ export function ControlPanel({
Travel Mode
</p>
<div className="grid grid-cols-2 gap-1">
{TRAVEL_MODES.map((m) => (
{TRAVEL_MODES.map((m, i) => {
const isRightCol = i % 2 === 1;
return (
<div key={m.value} className="relative group">
<button
key={m.value}
onClick={() => onModeChange(m.value)}
className={`flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
className={`w-full flex flex-col items-center gap-1 py-2 rounded-md text-xs border transition-colors ${
mode === m.value
? "border-brand-500 bg-brand-50 text-brand-700 font-medium"
: "border-gray-200 text-gray-600 hover:border-gray-300"
@ -105,7 +109,13 @@ export function ControlPanel({
<span className="text-lg">{m.icon}</span>
{m.label}
</button>
))}
<div className={`pointer-events-none absolute bottom-full mb-2 w-48 rounded-md bg-gray-900 px-2.5 py-1.5 text-[11px] leading-snug text-white opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg ${isRightCol ? "right-0" : "left-0"}`}>
{m.description}
<div className={`absolute top-full border-4 border-transparent border-t-gray-900 ${isRightCol ? "right-4" : "left-4"}`} />
</div>
</div>
);
})}
</div>
</div>

View file

@ -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,

View file

@ -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 {

View file

@ -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
))

View file

@ -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.

View file

@ -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;
};