129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
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 } };
|
|
|
|
// 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) {
|
|
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 });
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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).
|
|
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; 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) {
|
|
// 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" } },
|
|
);
|
|
}
|