From d0b29278bd5e3339eb558e14b4c6179e159cdf3c Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 7 Mar 2026 11:21:59 +0100 Subject: [PATCH] feat: mark which pois were used for scoring in the UI --- apps/web/app/api/reachable-pois/route.ts | 45 ++++++++++++++++++++---- apps/web/app/page.tsx | 2 +- apps/web/components/map-view.tsx | 16 ++++++--- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/apps/web/app/api/reachable-pois/route.ts b/apps/web/app/api/reachable-pois/route.ts index 1477e09..749d33b 100644 --- a/apps/web/app/api/reachable-pois/route.ts +++ b/apps/web/app/api/reachable-pois/route.ts @@ -8,6 +8,13 @@ const CACHE_TOLERANCE_M = 50; type CacheFeature = { geometry: object; properties: { contour: number } }; +// Which raw travel modes feed into each synthetic persona mode for scoring. +const DETAIL_MODES: Record = { + cyclist: ["walking", "cycling", "transit"], + cycling_walk: ["walking", "cycling"], + transit_walk: ["walking", "transit"], +}; + export async function GET(req: NextRequest) { console.log("[reachable-pois] GET called:", req.nextUrl.search); const { searchParams } = req.nextUrl; @@ -29,6 +36,9 @@ export async function GET(req: NextRequest) { mode === "transit_walk" ? "transit" : mode; + // Raw travel modes that feed into this mode's scoring (for scored POI lookup). + const detailModes = DETAIL_MODES[mode] ?? [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 // is set on the client (which is our fetch trigger). @@ -76,13 +86,34 @@ export async function GET(req: NextRequest) { return NextResponse.json({ pois: [], _debug: "no_polygon" }, { headers: { "Cache-Control": "no-store" } }); } - const pois = await Promise.resolve(sql<{ category: string; subcategory: string; name: string | null; lat: number; lng: number }[]>` - SELECT category, subcategory, name, - ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng - FROM raw_pois - WHERE city_slug = ${city} - AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) - ORDER BY category, subcategory, name + const pois = await Promise.resolve(sql<{ + category: string; subcategory: string; name: string | null; + lat: number; lng: number; scored: boolean; + }[]>` + WITH nearest_gp AS ( + SELECT id AS grid_point_id + FROM grid_points + WHERE city_slug = ${city} + ORDER BY geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326) + LIMIT 1 + ), + scored_ids AS ( + SELECT DISTINCT gpd.nearest_poi_id + FROM grid_poi_details gpd + CROSS JOIN nearest_gp ng + WHERE gpd.grid_point_id = ng.grid_point_id + AND gpd.travel_mode = ANY(${detailModes}) + AND gpd.nearest_poi_id IS NOT NULL + ) + SELECT + p.category, p.subcategory, p.name, + ST_Y(p.geom)::float AS lat, ST_X(p.geom)::float AS lng, + (s.nearest_poi_id IS NOT NULL) AS scored + FROM raw_pois p + LEFT JOIN scored_ids s ON p.osm_id = s.nearest_poi_id + WHERE p.city_slug = ${city} + AND ST_Intersects(p.geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) + ORDER BY p.category, p.subcategory, p.name `); if (pois.length === 0) { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index c2dafcf..dc27a1c 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -47,7 +47,7 @@ export default function HomePage() { const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState(null); // Reachable POI pins - type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number }; + type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number; scored: boolean }; const [reachablePois, setReachablePois] = useState([]); const [showPois, setShowPois] = useState(true); diff --git a/apps/web/components/map-view.tsx b/apps/web/components/map-view.tsx index a1ef4d3..d9c593f 100644 --- a/apps/web/components/map-view.tsx +++ b/apps/web/components/map-view.tsx @@ -13,6 +13,7 @@ export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem"; export type ReachablePoi = { category: string; subcategory: string; + scored?: boolean; name: string | null; lat: number; lng: number; @@ -503,7 +504,7 @@ export function MapView({ features: reachablePois.map((poi) => ({ type: "Feature", geometry: { type: "Point", coordinates: [poi.lng, poi.lat] }, - properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name }, + properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name, scored: poi.scored ?? false }, })), }; @@ -527,10 +528,17 @@ export function MapView({ paint: { // eslint-disable-next-line @typescript-eslint/no-explicit-any "circle-color": colorMatch as any, - "circle-radius": 6, + // Scored POIs (in KNN set) are larger and fully opaque; + // out-of-KNN POIs are smaller and faded. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "circle-radius": ["case", ["get", "scored"], 6, 4] as any, "circle-stroke-color": "#ffffff", - "circle-stroke-width": 1.5, - "circle-opacity": 0.85, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "circle-stroke-width": ["case", ["get", "scored"], 1.5, 1] as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "circle-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "circle-stroke-opacity": ["case", ["get", "scored"], 0.9, 0.5] as any, }, }); }