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