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