126 lines
4.3 KiB
TypeScript
126 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, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared";
|
|
import type { GridCell, HeatmapPayload, CategoryId } from "@transportationer/shared";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
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 (!(TRAVEL_MODES as readonly string[]).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" },
|
|
});
|
|
}
|