173 lines
6.6 KiB
TypeScript
173 lines
6.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { sql } from "@/lib/db";
|
|
import type { CategoryId, ProfileId } from "@transportationer/shared";
|
|
import {
|
|
CATEGORY_IDS,
|
|
PROFILE_IDS,
|
|
} 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") ?? "");
|
|
const lng = parseFloat(p.get("lng") ?? "");
|
|
const city = p.get("city") ?? "";
|
|
const mode = p.get("mode") ?? "walking";
|
|
const threshold = parseInt(p.get("threshold") ?? "15", 10);
|
|
const profileId = (PROFILE_IDS as readonly string[]).includes(p.get("profile") ?? "")
|
|
? (p.get("profile") as ProfileId)
|
|
: "universal" as ProfileId;
|
|
|
|
if (isNaN(lat) || isNaN(lng)) {
|
|
return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 });
|
|
}
|
|
if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 });
|
|
if (!VALID_MODES.includes(mode)) {
|
|
return NextResponse.json({ error: "Invalid mode" }, { status: 400 });
|
|
}
|
|
|
|
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>
|
|
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
|
|
);
|
|
|
|
const rows = await Promise.resolve(sql<{
|
|
grid_point_id: string;
|
|
grid_lat: number;
|
|
grid_lng: number;
|
|
score_service_trade: number | null;
|
|
score_transport: number | null;
|
|
score_work_school: number | null;
|
|
score_culture_community: number | null;
|
|
score_recreation: number | null;
|
|
dist_service_trade: number | null;
|
|
dist_transport: number | null;
|
|
dist_work_school: number | null;
|
|
dist_culture_community: number | null;
|
|
dist_recreation: number | null;
|
|
time_service_trade: number | null;
|
|
time_transport: number | null;
|
|
time_work_school: number | null;
|
|
time_culture_community: number | null;
|
|
time_recreation: number | null;
|
|
}[]>`
|
|
WITH nearest AS (
|
|
SELECT gp.id,
|
|
gp.id::text AS grid_point_id,
|
|
ST_Y(gp.geom)::float AS grid_lat,
|
|
ST_X(gp.geom)::float AS grid_lng
|
|
FROM grid_points gp
|
|
WHERE gp.city_slug = ${city}
|
|
ORDER BY gp.geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)
|
|
LIMIT 1
|
|
)
|
|
SELECT
|
|
n.grid_point_id,
|
|
n.grid_lat, n.grid_lng,
|
|
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,
|
|
MAX(gs.distance_m) FILTER (WHERE gs.category = 'service_trade') AS dist_service_trade,
|
|
MAX(gs.distance_m) FILTER (WHERE gs.category = 'transport') AS dist_transport,
|
|
MAX(gs.distance_m) FILTER (WHERE gs.category = 'work_school') AS dist_work_school,
|
|
MAX(gs.distance_m) FILTER (WHERE gs.category = 'culture_community') AS dist_culture_community,
|
|
MAX(gs.distance_m) FILTER (WHERE gs.category = 'recreation') AS dist_recreation,
|
|
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'service_trade') AS time_service_trade,
|
|
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'transport') AS time_transport,
|
|
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'work_school') AS time_work_school,
|
|
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'culture_community') AS time_culture_community,
|
|
MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'recreation') AS time_recreation
|
|
FROM nearest n
|
|
JOIN grid_scores gs ON gs.grid_point_id = n.id
|
|
WHERE gs.travel_mode = ${mode}
|
|
AND gs.threshold_min = ${closestThreshold}
|
|
AND gs.profile = ${profileId}
|
|
GROUP BY n.grid_point_id, n.grid_lat, n.grid_lng
|
|
`);
|
|
|
|
if (rows.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "No grid data for this location" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
const r = rows[0];
|
|
|
|
const categoryScores: 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,
|
|
};
|
|
|
|
const distancesM: Partial<Record<CategoryId, number>> = {
|
|
service_trade: r.dist_service_trade ?? undefined,
|
|
transport: r.dist_transport ?? undefined,
|
|
work_school: r.dist_work_school ?? undefined,
|
|
culture_community: r.dist_culture_community ?? undefined,
|
|
recreation: r.dist_recreation ?? undefined,
|
|
};
|
|
|
|
const travelTimesS: Partial<Record<CategoryId, number>> = {
|
|
service_trade: r.time_service_trade ?? undefined,
|
|
transport: r.time_transport ?? undefined,
|
|
work_school: r.time_work_school ?? undefined,
|
|
culture_community: r.time_culture_community ?? undefined,
|
|
recreation: r.time_recreation ?? undefined,
|
|
};
|
|
|
|
// Fetch nearest POI per subcategory for this grid point and mode.
|
|
const detailRows = await Promise.resolve(sql<{
|
|
category: string;
|
|
subcategory: string;
|
|
nearest_poi_name: string | null;
|
|
distance_m: number | null;
|
|
travel_time_s: number | null;
|
|
}[]>`
|
|
SELECT 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
|
|
`);
|
|
|
|
type SubcategoryDetail = {
|
|
subcategory: string;
|
|
name: string | null;
|
|
distanceM: number | null;
|
|
travelTimeS: number | null;
|
|
};
|
|
|
|
const subcategoryDetails: Partial<Record<CategoryId, SubcategoryDetail[]>> = {};
|
|
for (const cat of CATEGORY_IDS) {
|
|
subcategoryDetails[cat] = [];
|
|
}
|
|
for (const row of detailRows) {
|
|
const cat = row.category as CategoryId;
|
|
if (subcategoryDetails[cat]) {
|
|
subcategoryDetails[cat]!.push({
|
|
subcategory: row.subcategory,
|
|
name: row.nearest_poi_name,
|
|
distanceM: row.distance_m,
|
|
travelTimeS: row.travel_time_s,
|
|
});
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
lat: r.grid_lat,
|
|
lng: r.grid_lng,
|
|
categoryScores,
|
|
distancesM,
|
|
travelTimesS,
|
|
subcategoryDetails,
|
|
profile: profileId,
|
|
});
|
|
}
|