From b891ca79acd7080f3b9a1e424d089fde968c3f46 Mon Sep 17 00:00:00 2001
From: Jan-Henrik Bruhn
Date: Wed, 4 Mar 2026 16:32:00 +0100
Subject: [PATCH] fix: isochrone for poi selection and calculation was wrong,
now it is not
---
apps/web/app/api/grid/route.ts | 7 +-
apps/web/app/api/isochrones/route.ts | 11 +-
apps/web/app/api/location-score/route.ts | 19 ++-
apps/web/app/api/reachable-pois/route.ts | 95 ++++++++++++
.../web/app/api/tiles/grid/[...tile]/route.ts | 7 +-
apps/web/app/page.tsx | 43 ++++--
apps/web/components/control-panel.tsx | 5 +-
apps/web/components/location-score-panel.tsx | 22 +++
apps/web/components/map-view.tsx | 137 +++++++++++++++++-
apps/web/lib/isochrone.ts | 9 ++
apps/web/lib/valhalla.ts | 9 +-
shared/src/osm-tags.ts | 3 +
worker/src/jobs/refresh-city.ts | 4 +-
13 files changed, 331 insertions(+), 40 deletions(-)
create mode 100644 apps/web/app/api/reachable-pois/route.ts
create mode 100644 apps/web/lib/isochrone.ts
diff --git a/apps/web/app/api/grid/route.ts b/apps/web/app/api/grid/route.ts
index 314c869..9591dfe 100644
--- a/apps/web/app/api/grid/route.ts
+++ b/apps/web/app/api/grid/route.ts
@@ -2,14 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import { compositeScore } from "@/lib/scoring";
-import { CATEGORY_IDS, DEFAULT_WEIGHTS } from "@transportationer/shared";
+import { CATEGORY_IDS, DEFAULT_WEIGHTS, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared";
import type { GridCell, HeatmapPayload, CategoryId } from "@transportationer/shared";
export const runtime = "nodejs";
-const VALID_MODES = ["walking", "cycling", "driving"];
-const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
-
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "berlin";
@@ -17,7 +14,7 @@ export async function GET(req: NextRequest) {
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const bboxStr = p.get("bbox");
- if (!VALID_MODES.includes(mode)) {
+ if (!(TRAVEL_MODES as readonly string[]).includes(mode)) {
return NextResponse.json(
{ error: "Invalid mode", code: "INVALID_MODE" },
{ status: 400 },
diff --git a/apps/web/app/api/isochrones/route.ts b/apps/web/app/api/isochrones/route.ts
index 19c580b..e70e942 100644
--- a/apps/web/app/api/isochrones/route.ts
+++ b/apps/web/app/api/isochrones/route.ts
@@ -52,7 +52,9 @@ export async function POST(req: NextRequest) {
`);
if (cached.length > 0) {
- return NextResponse.json({ ...cached[0].result, cached: true });
+ const raw = cached[0].result;
+ const result = typeof raw === "string" ? JSON.parse(raw as string) : raw;
+ return NextResponse.json({ ...result, cached: true });
}
// Refuse to call valhalla_service while tiles are being rebuilt —
@@ -86,14 +88,17 @@ export async function POST(req: NextRequest) {
);
}
- // Store in PostGIS cache
+ // Store in PostGIS cache.
+ // Use an explicit ::jsonb cast so PostgreSQL receives a text parameter and
+ // parses it as JSON itself. Without the cast, postgres.js infers the JSONB
+ // column type and re-encodes the string as a JSONB string literal.
await Promise.resolve(sql`
INSERT INTO isochrone_cache (origin_geom, travel_mode, contours_min, result)
VALUES (
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326),
${mode},
${contours},
- ${JSON.stringify(geojson)}
+ ${JSON.stringify(geojson)}::jsonb
)
`);
diff --git a/apps/web/app/api/location-score/route.ts b/apps/web/app/api/location-score/route.ts
index 0bb02f3..5e64e59 100644
--- a/apps/web/app/api/location-score/route.ts
+++ b/apps/web/app/api/location-score/route.ts
@@ -4,13 +4,12 @@ import type { CategoryId, ProfileId } from "@transportationer/shared";
import {
CATEGORY_IDS,
PROFILE_IDS,
+ TRAVEL_MODES,
+ VALID_THRESHOLDS,
} from "@transportationer/shared";
export const runtime = "nodejs";
-const VALID_MODES = ["walking", "cycling", "driving", "fifteen"];
-const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
-
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const lat = parseFloat(p.get("lat") ?? "");
@@ -26,7 +25,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 });
}
if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 });
- if (!VALID_MODES.includes(mode)) {
+ if (!(TRAVEL_MODES as readonly string[]).includes(mode)) {
return NextResponse.json({ error: "Invalid mode" }, { status: 400 });
}
@@ -123,6 +122,11 @@ 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];
+
// Fetch nearest POI per subcategory for this grid point and mode.
const detailRows = await Promise.resolve(sql<{
category: string;
@@ -131,11 +135,12 @@ export async function GET(req: NextRequest) {
distance_m: number | null;
travel_time_s: number | null;
}[]>`
- SELECT category, subcategory, nearest_poi_name, distance_m, travel_time_s
+ SELECT DISTINCT ON (category, subcategory)
+ category, subcategory, nearest_poi_name, distance_m, travel_time_s
FROM grid_poi_details
WHERE grid_point_id = ${r.grid_point_id}::bigint
- AND travel_mode = ${mode}
- ORDER BY category, distance_m
+ AND travel_mode = ANY(${detailModes})
+ ORDER BY category, subcategory, travel_time_s ASC NULLS LAST
`);
type SubcategoryDetail = {
diff --git a/apps/web/app/api/reachable-pois/route.ts b/apps/web/app/api/reachable-pois/route.ts
new file mode 100644
index 0000000..ec12c52
--- /dev/null
+++ b/apps/web/app/api/reachable-pois/route.ts
@@ -0,0 +1,95 @@
+import { NextRequest, NextResponse } from "next/server";
+import { sql } from "@/lib/db";
+import { isochroneContours } from "@/lib/isochrone";
+
+export const runtime = "nodejs";
+
+const CACHE_TOLERANCE_M = 50;
+
+type CacheFeature = { geometry: object; properties: { contour: number } };
+
+export async function GET(req: NextRequest) {
+ console.log("[reachable-pois] GET called:", req.nextUrl.search);
+ const { searchParams } = req.nextUrl;
+ const city = searchParams.get("city");
+ const lat = parseFloat(searchParams.get("lat") ?? "");
+ const lng = parseFloat(searchParams.get("lng") ?? "");
+ const mode = searchParams.get("mode") ?? "walking";
+ const threshold = parseInt(searchParams.get("threshold") ?? "15", 10);
+
+ if (!city || isNaN(lat) || isNaN(lng)) {
+ 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;
+
+ // 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).
+ const contours = isochroneContours(threshold);
+
+ const cached = await Promise.resolve(sql<{ result: { features: CacheFeature[] } }[]>`
+ SELECT result
+ FROM isochrone_cache
+ WHERE travel_mode = ${isoMode}
+ AND contours_min = ${contours}
+ AND ST_DWithin(
+ origin_geom::geography,
+ ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography,
+ ${CACHE_TOLERANCE_M}
+ )
+ ORDER BY created_at DESC
+ LIMIT 1
+ `);
+
+ if (cached.length === 0) {
+ // Should not happen if client fires this only after isochroneData is set.
+ console.warn(`[reachable-pois] cache miss for ${city} ${lat},${lng} mode=${isoMode} contours=${JSON.stringify(contours)}`);
+ return NextResponse.json({ pois: [], _debug: "cache_miss" }, { headers: { "Cache-Control": "no-store" } });
+ }
+
+ // Existing cache entries may have been stored with JSON.stringify() (double-encoded)
+ // resulting in a JSONB string instead of a JSONB object. Parse those on read.
+ const rawResult = cached[0].result;
+ const result: { features?: CacheFeature[] } =
+ typeof rawResult === "string" ? JSON.parse(rawResult as string) : rawResult;
+
+ const features = result?.features;
+ if (!Array.isArray(features) || features.length === 0) {
+ console.warn(`[reachable-pois] bad features: ${JSON.stringify(rawResult).slice(0, 200)}`);
+ return NextResponse.json({ pois: [], _debug: "bad_features" }, { headers: { "Cache-Control": "no-store" } });
+ }
+
+ // Pick the feature matching the exact threshold contour, or fall back to the largest.
+ const contourValues = features.map((f) => f.properties?.contour);
+ const match = features.find((f) => f.properties?.contour === threshold);
+ const polygon = (match ?? features[features.length - 1])?.geometry;
+ console.log(`[reachable-pois] features=${features.length} contours=${JSON.stringify(contourValues)} match=${!!match} threshold=${threshold}`);
+ if (!polygon) {
+ console.warn(`[reachable-pois] no polygon geometry`);
+ 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
+ `);
+
+ if (pois.length === 0) {
+ // Log to help distinguish "no POIs in polygon" from a query error.
+ console.warn(`[reachable-pois] 0 pois for ${city} (cache hit, polygon type=${(polygon as { type?: string }).type})`);
+ return NextResponse.json({ pois: [], _debug: "empty_spatial" }, { headers: { "Cache-Control": "no-store" } });
+ }
+
+ return NextResponse.json(
+ { pois },
+ { headers: { "Cache-Control": "public, max-age=3600" } },
+ );
+}
diff --git a/apps/web/app/api/tiles/grid/[...tile]/route.ts b/apps/web/app/api/tiles/grid/[...tile]/route.ts
index 010d966..e971dfc 100644
--- a/apps/web/app/api/tiles/grid/[...tile]/route.ts
+++ b/apps/web/app/api/tiles/grid/[...tile]/route.ts
@@ -1,12 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
-import { PROFILE_IDS } from "@transportationer/shared";
+import { PROFILE_IDS, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared";
export const runtime = "nodejs";
-const VALID_MODES = ["walking", "cycling", "driving", "transit", "fifteen"];
-const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
-
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ tile: string[] }> },
@@ -30,7 +27,7 @@ export async function GET(
const profile = p.get("profile") ?? "universal";
if (!city) return new NextResponse("Missing city", { status: 400 });
- if (!VALID_MODES.includes(mode)) return new NextResponse("Invalid mode", { status: 400 });
+ if (!(TRAVEL_MODES as readonly string[]).includes(mode)) return new NextResponse("Invalid mode", { status: 400 });
const validProfile = (PROFILE_IDS as readonly string[]).includes(profile) ? profile : "universal";
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 936ba8f..73424a1 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -19,21 +19,13 @@ import {
type OverlayMode,
} from "@/components/location-score-panel";
import { MapLegend } from "@/components/map-legend";
+import { isochroneContours } from "@/lib/isochrone";
const MapView = dynamic(
() => import("@/components/map-view").then((m) => m.MapView),
{ ssr: false, loading: () => },
);
-/** Compute 3 evenly-spaced contour values up to the threshold (deduped, min 1). */
-function isochroneContours(threshold: number): number[] {
- const raw = [
- Math.max(1, Math.round(threshold / 3)),
- Math.max(2, Math.round((threshold * 2) / 3)),
- threshold,
- ];
- return [...new Set(raw)];
-}
export default function HomePage() {
const [cities, setCities] = useState([]);
@@ -53,6 +45,11 @@ export default function HomePage() {
const [pinEstatePercentile, setPinEstatePercentile] = useState(null);
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState(null);
+ // Reachable POI pins
+ type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number };
+ const [reachablePois, setReachablePois] = useState([]);
+ const [showPois, setShowPois] = useState(true);
+
// Overlay mode: isochrone (new default) or relative heatmap
const [overlayMode, setOverlayMode] = useState("isochrone");
const [isochroneData, setIsochroneData] = useState
- {THRESHOLDS.map((t) => (
+ {VALID_THRESHOLDS.map((t) => (
+ {/* POI pin toggle */}
+ {poiCount > 0 && (
+
+
+
+ )}
+
{/* Per-category score bars */}
{CATEGORIES.map((cat) => {
diff --git a/apps/web/components/map-view.tsx b/apps/web/components/map-view.tsx
index b202466..13a8df7 100644
--- a/apps/web/components/map-view.tsx
+++ b/apps/web/components/map-view.tsx
@@ -10,6 +10,22 @@ type Weights = Record
;
export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
+export type ReachablePoi = {
+ category: string;
+ subcategory: string;
+ name: string | null;
+ lat: number;
+ lng: number;
+};
+
+const CATEGORY_COLORS: Record = {
+ service_trade: "#f59e0b",
+ transport: "#0ea5e9",
+ work_school: "#8b5cf6",
+ culture_community: "#ec4899",
+ recreation: "#10b981",
+};
+
export interface MapViewProps {
citySlug: string;
cityBbox: [number, number, number, number];
@@ -25,6 +41,8 @@ export interface MapViewProps {
isochrones?: object | null;
/** Which base overlay to show (accessibility grid, estate value, or hidden gem). */
baseOverlay?: BaseOverlay;
+ /** Reachable POI pins to show on the map. */
+ reachablePois?: ReachablePoi[] | null;
onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void;
}
@@ -98,6 +116,11 @@ function removeEstateValueLayers(map: maplibregl.Map) {
if (map.getSource("estate-value-zones")) map.removeSource("estate-value-zones");
}
+function removePoiLayers(map: maplibregl.Map) {
+ if (map.getLayer("poi-circles")) map.removeLayer("poi-circles");
+ if (map.getSource("reachable-pois")) map.removeSource("reachable-pois");
+}
+
function removeHiddenGemLayers(map: maplibregl.Map) {
if (map.getLayer("hidden-gems-fill")) map.removeLayer("hidden-gems-fill");
if (map.getSource("hidden-gems-tiles")) map.removeSource("hidden-gems-tiles");
@@ -142,6 +165,7 @@ export function MapView({
pinCategoryScores,
isochrones,
baseOverlay = "accessibility",
+ reachablePois,
onLocationClick,
}: MapViewProps) {
const containerRef = useRef(null);
@@ -159,6 +183,15 @@ export function MapView({
onLeave: () => void;
} | null>(null);
+ // Ref for POI layer event handlers
+ const poiHandlersRef = useRef<{
+ onEnter: () => void;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onMove: (e: any) => void;
+ onLeave: () => void;
+ } | null>(null);
+ const poiPopupRef = useRef(null);
+
// Tracked as state so effects re-run once the map style finishes loading.
// Without this, effects that check isStyleLoaded() return early on first render
// and never reapply state after the async on("load") fires.
@@ -264,7 +297,7 @@ export function MapView({
maxContour * 0.5, "#fee08b",
maxContour, "#d73027",
] as any,
- "fill-opacity": 0.5,
+ "fill-opacity": 0.3,
"fill-outline-color": "rgba(0,0,0,0.1)",
},
});
@@ -440,6 +473,108 @@ export function MapView({
};
}, [mapLoaded, showEstateValue, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps
+ // ── Reachable POI pins ────────────────────────────────────────────────────
+ useEffect(() => {
+ if (!mapLoaded) return;
+ const map = mapRef.current!;
+
+ // Clean up previous handlers
+ if (poiHandlersRef.current) {
+ const { onEnter, onMove, onLeave } = poiHandlersRef.current;
+ map.off("mouseenter", "poi-circles", onEnter);
+ map.off("mousemove", "poi-circles", onMove);
+ map.off("mouseleave", "poi-circles", onLeave);
+ poiHandlersRef.current = null;
+ }
+ poiPopupRef.current?.remove();
+ poiPopupRef.current = null;
+
+ if (!reachablePois || reachablePois.length === 0) {
+ removePoiLayers(map);
+ return;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const geojson: any = {
+ type: "FeatureCollection",
+ features: reachablePois.map((poi) => ({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [poi.lng, poi.lat] },
+ properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name },
+ })),
+ };
+
+ const existingSource = map.getSource("reachable-pois") as maplibregl.GeoJSONSource | undefined;
+ if (existingSource) {
+ existingSource.setData(geojson);
+ } else {
+ map.addSource("reachable-pois", { type: "geojson", data: geojson });
+
+ // Build match expression for category colors
+ const colorMatch: unknown[] = ["match", ["get", "category"]];
+ for (const [cat, color] of Object.entries(CATEGORY_COLORS)) {
+ colorMatch.push(cat, color);
+ }
+ colorMatch.push("#888888"); // fallback
+
+ map.addLayer({
+ id: "poi-circles",
+ type: "circle",
+ source: "reachable-pois",
+ paint: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ "circle-color": colorMatch as any,
+ "circle-radius": 6,
+ "circle-stroke-color": "#ffffff",
+ "circle-stroke-width": 1.5,
+ "circle-opacity": 0.85,
+ },
+ });
+ }
+
+ const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" });
+ poiPopupRef.current = popup;
+
+ const onEnter = () => { map.getCanvas().style.cursor = "pointer"; };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const onMove = (e: any) => {
+ const feature = e.features?.[0];
+ if (!feature) return;
+ const name = feature.properties?.name as string | null;
+ const subcategory = feature.properties?.subcategory as string;
+ popup.setLngLat(e.lngLat).setHTML(
+ `
+
${name ?? subcategory}
+ ${name ? `
${subcategory}
` : ""}
+
`
+ ).addTo(map);
+ };
+ const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); };
+
+ map.on("mouseenter", "poi-circles", onEnter);
+ map.on("mousemove", "poi-circles", onMove);
+ map.on("mouseleave", "poi-circles", onLeave);
+ poiHandlersRef.current = { onEnter, onMove, onLeave };
+
+ return () => {
+ if (poiHandlersRef.current) {
+ const { onEnter: e, onMove: m, onLeave: l } = poiHandlersRef.current;
+ map.off("mouseenter", "poi-circles", e);
+ map.off("mousemove", "poi-circles", m);
+ map.off("mouseleave", "poi-circles", l);
+ poiHandlersRef.current = null;
+ }
+ try {
+ if (map.isStyleLoaded()) {
+ removePoiLayers(map);
+ map.getCanvas().style.cursor = "crosshair";
+ }
+ } catch { /* map removed */ }
+ poiPopupRef.current?.remove();
+ poiPopupRef.current = null;
+ };
+ }, [mapLoaded, reachablePois]);
+
// ── Initialize map (runs once on mount) ───────────────────────────────────
useEffect(() => {
if (mountedRef.current || !containerRef.current) return;
diff --git a/apps/web/lib/isochrone.ts b/apps/web/lib/isochrone.ts
new file mode 100644
index 0000000..3225b00
--- /dev/null
+++ b/apps/web/lib/isochrone.ts
@@ -0,0 +1,9 @@
+/** Compute up to 3 evenly-spaced contour values up to threshold (deduped, min 1). */
+export function isochroneContours(threshold: number): number[] {
+ const raw = [
+ Math.max(1, Math.round(threshold / 3)),
+ Math.max(2, Math.round((threshold * 2) / 3)),
+ threshold,
+ ];
+ return [...new Set(raw)];
+}
diff --git a/apps/web/lib/valhalla.ts b/apps/web/lib/valhalla.ts
index d3b5945..03650f5 100644
--- a/apps/web/lib/valhalla.ts
+++ b/apps/web/lib/valhalla.ts
@@ -17,14 +17,19 @@ export interface IsochroneOpts {
}
export async function fetchIsochrone(opts: IsochroneOpts): Promise