feat: mark which pois were used for scoring in the UI

This commit is contained in:
Jan-Henrik 2026-03-07 11:21:59 +01:00
parent 09d23e6666
commit d0b29278bd
3 changed files with 51 additions and 12 deletions

View file

@ -8,6 +8,13 @@ const CACHE_TOLERANCE_M = 50;
type CacheFeature = { geometry: object; properties: { contour: number } }; type CacheFeature = { geometry: object; properties: { contour: number } };
// Which raw travel modes feed into each synthetic persona mode for scoring.
const DETAIL_MODES: Record<string, string[]> = {
cyclist: ["walking", "cycling", "transit"],
cycling_walk: ["walking", "cycling"],
transit_walk: ["walking", "transit"],
};
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
console.log("[reachable-pois] GET called:", req.nextUrl.search); console.log("[reachable-pois] GET called:", req.nextUrl.search);
const { searchParams } = req.nextUrl; const { searchParams } = req.nextUrl;
@ -29,6 +36,9 @@ export async function GET(req: NextRequest) {
mode === "transit_walk" ? "transit" : mode === "transit_walk" ? "transit" :
mode; 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, // 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 // so the exact-match lookup is guaranteed to find the entry once isochroneData
// is set on the client (which is our fetch trigger). // 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" } }); 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 }[]>` const pois = await Promise.resolve(sql<{
SELECT category, subcategory, name, category: string; subcategory: string; name: string | null;
ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng lat: number; lng: number; scored: boolean;
FROM raw_pois }[]>`
WHERE city_slug = ${city} WITH nearest_gp AS (
AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) SELECT id AS grid_point_id
ORDER BY category, subcategory, name 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) { if (pois.length === 0) {

View file

@ -47,7 +47,7 @@ export default function HomePage() {
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null); const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
// Reachable POI pins // 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<ReachablePoi[]>([]); const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
const [showPois, setShowPois] = useState(true); const [showPois, setShowPois] = useState(true);

View file

@ -13,6 +13,7 @@ export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
export type ReachablePoi = { export type ReachablePoi = {
category: string; category: string;
subcategory: string; subcategory: string;
scored?: boolean;
name: string | null; name: string | null;
lat: number; lat: number;
lng: number; lng: number;
@ -503,7 +504,7 @@ export function MapView({
features: reachablePois.map((poi) => ({ features: reachablePois.map((poi) => ({
type: "Feature", type: "Feature",
geometry: { type: "Point", coordinates: [poi.lng, poi.lat] }, 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: { paint: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-color": colorMatch as 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-color": "#ffffff",
"circle-stroke-width": 1.5, // eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-opacity": 0.85, "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,
}, },
}); });
} }