89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { sql } from "@/lib/db";
|
|
import { PROFILE_IDS } 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[] }> },
|
|
) {
|
|
const { tile } = await params;
|
|
if (tile.length !== 3) {
|
|
return new NextResponse("Invalid tile path", { status: 400 });
|
|
}
|
|
const z = parseInt(tile[0], 10);
|
|
const x = parseInt(tile[1], 10);
|
|
const y = parseInt(tile[2], 10);
|
|
|
|
if ([z, x, y].some(isNaN)) {
|
|
return new NextResponse("Invalid tile coordinates", { status: 400 });
|
|
}
|
|
|
|
const p = req.nextUrl.searchParams;
|
|
const city = p.get("city") ?? "";
|
|
const mode = p.get("mode") ?? "walking";
|
|
const threshold = parseInt(p.get("threshold") ?? "15", 10);
|
|
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 });
|
|
const validProfile = (PROFILE_IDS as readonly string[]).includes(profile) ? profile : "universal";
|
|
|
|
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>
|
|
Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev,
|
|
);
|
|
|
|
try {
|
|
const rows = await Promise.resolve(sql<{ mvt: Uint8Array }[]>`
|
|
WITH
|
|
envelope AS (SELECT ST_TileEnvelope(${z}, ${x}, ${y}) AS env),
|
|
city_info AS (SELECT COALESCE(resolution_m, 200) AS resolution_m FROM cities WHERE slug = ${city})
|
|
SELECT ST_AsMVT(t, 'grid', 4096, 'geom') AS mvt
|
|
FROM (
|
|
SELECT
|
|
ST_AsMVTGeom(
|
|
ST_Expand(ST_Transform(gp.geom, 3857), ci.resolution_m::float / 2),
|
|
e.env,
|
|
4096, 0, true
|
|
) AS geom,
|
|
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
|
|
CROSS JOIN envelope e
|
|
CROSS JOIN city_info ci
|
|
WHERE gp.city_slug = ${city}
|
|
AND gs.travel_mode = ${mode}
|
|
AND gs.threshold_min = ${closestThreshold}
|
|
AND gs.profile = ${validProfile}
|
|
AND ST_Intersects(
|
|
ST_Transform(gp.geom, 3857),
|
|
ST_Expand(e.env, ci.resolution_m::float / 2)
|
|
)
|
|
GROUP BY gp.id, gp.geom, e.env, ci.resolution_m
|
|
) t
|
|
WHERE t.geom IS NOT NULL
|
|
`);
|
|
|
|
const buf = rows[0]?.mvt;
|
|
const data = buf ? new Uint8Array(buf) : new Uint8Array(0);
|
|
|
|
return new NextResponse(data, {
|
|
headers: {
|
|
"Content-Type": "application/x-protobuf",
|
|
"Cache-Control": "public, max-age=300",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("[tiles/grid] Error:", err);
|
|
return new NextResponse("Internal Server Error", { status: 500 });
|
|
}
|
|
}
|