fifteen/apps/web/app/api/grid/route.ts
2026-03-01 21:58:53 +01:00

129 lines
4.3 KiB
TypeScript

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 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";
const mode = p.get("mode") ?? "walking";
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const bboxStr = p.get("bbox");
if (!VALID_MODES.includes(mode)) {
return NextResponse.json(
{ error: "Invalid mode", code: "INVALID_MODE" },
{ status: 400 },
);
}
// Find closest pre-computed threshold
const closestThreshold =
VALID_THRESHOLDS.reduce((prev, curr) =>
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
);
// Parse weights
let weights: Record<CategoryId, number>;
try {
const w = p.get("weights");
weights = w ? JSON.parse(w) : DEFAULT_WEIGHTS;
// Fill missing keys with defaults
for (const cat of CATEGORY_IDS) {
if (weights[cat] === undefined) weights[cat] = DEFAULT_WEIGHTS[cat];
}
} catch {
return NextResponse.json(
{ error: "Invalid weights JSON", code: "INVALID_WEIGHTS" },
{ status: 400 },
);
}
const cacheKey = `api:grid:${hashParams({ city, mode, threshold: closestThreshold, weights, bbox: bboxStr })}`;
const cached = await cacheGet<HeatmapPayload>(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { "X-Cache": "HIT", "Cache-Control": "public, s-maxage=60" },
});
}
let bboxFilter = sql`TRUE`;
if (bboxStr) {
const parts = bboxStr.split(",").map(Number);
if (parts.length === 4 && !parts.some(isNaN)) {
const [minLng, minLat, maxLng, maxLat] = parts;
bboxFilter = sql`ST_Within(gp.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))`;
}
}
const rows = await Promise.resolve(sql<{
grid_x: number;
grid_y: number;
lng: number;
lat: number;
score_service_trade: number | null;
score_transport: number | null;
score_work_school: number | null;
score_culture_community: number | null;
score_recreation: number | null;
}[]>`
SELECT
gp.grid_x,
gp.grid_y,
ST_X(gp.geom)::float AS lng,
ST_Y(gp.geom)::float AS lat,
MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade,
MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport,
MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school,
MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community,
MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation
FROM grid_points gp
JOIN grid_scores gs ON gs.grid_point_id = gp.id
WHERE gp.city_slug = ${city}
AND gs.travel_mode = ${mode}
AND gs.threshold_min = ${closestThreshold}
AND ${bboxFilter}
GROUP BY gp.grid_x, gp.grid_y, gp.geom
ORDER BY gp.grid_y, gp.grid_x
`);
const cells: GridCell[] = rows.map((r) => {
const categoryScores: Partial<Record<CategoryId, number>> = {
service_trade: r.score_service_trade ?? 0,
transport: r.score_transport ?? 0,
work_school: r.score_work_school ?? 0,
culture_community: r.score_culture_community ?? 0,
recreation: r.score_recreation ?? 0,
};
return {
gridX: r.grid_x,
gridY: r.grid_y,
lng: r.lng,
lat: r.lat,
score: compositeScore(categoryScores, weights),
categoryScores,
};
});
const payload: HeatmapPayload = {
citySlug: city,
travelMode: mode as HeatmapPayload["travelMode"],
thresholdMin: closestThreshold,
weights,
gridSpacingM: 200,
cells,
generatedAt: new Date().toISOString(),
};
await cacheSet(cacheKey, payload, "API_GRID");
return NextResponse.json(payload, {
headers: { "X-Cache": "MISS", "Cache-Control": "public, s-maxage=60" },
});
}