fifteen/apps/web/app/api/tiles/grid/[...tile]/route.ts
2026-03-01 21:59:44 +01:00

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"];
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 });
}
}