import { NextResponse } from "next/server"; import { sql } from "@/lib/db"; export const runtime = "nodejs"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); // ── Point query: ?lat=&lng= ─────────────────────────────────────────────── const latParam = searchParams.get("lat"); const lngParam = searchParams.get("lng"); if (latParam !== null && lngParam !== null) { const lat = Number(latParam); const lng = Number(lngParam); if (isNaN(lat) || isNaN(lng)) { return NextResponse.json({ error: "invalid lat/lng" }, { status: 400 }); } // Optional params for score-based percentile comparison. const cityParam = searchParams.get("city"); const modeParam = searchParams.get("mode"); const profileParam = searchParams.get("profile"); const thresholdNum = Number(searchParams.get("threshold") ?? ""); const hasScoreParams = cityParam && modeParam && profileParam && !isNaN(thresholdNum) && thresholdNum > 0; // Run both queries in parallel: zone info + 5km radius percentile, and // (optionally) score-based percentile among zones with similar accessibility. const [mainRows, scoreRows] = await Promise.all([ Promise.resolve(sql<{ geom_json: string; value: number | null; zone_name: string | null; usage_type: string | null; usage_detail: string | null; dev_state: string | null; stichtag: string | null; percentile_rank: number | null; nearby_count: number; }[]>` WITH pt AS ( SELECT ST_SetSRID(ST_Point(${lng}, ${lat}), 4326) AS geom ), -- Use only the latest year's data for this city so historical rows -- don't create duplicate zone polygons on top of each other. latest_year AS ( SELECT MAX(year) AS yr FROM estate_value_zones WHERE source = 'boris-ni' AND city_slug = ( SELECT city_slug FROM estate_value_zones, pt WHERE ST_Within(pt.geom, estate_value_zones.geom) LIMIT 1 ) ), nearby AS ( SELECT ez.value_eur_m2 FROM estate_value_zones ez, pt, latest_year WHERE ez.value_eur_m2 IS NOT NULL AND ST_DWithin(ez.geom::geography, pt.geom::geography, 5000) AND (ez.year IS NULL OR ez.year = latest_year.yr) ) SELECT ST_AsGeoJSON(ez.geom) AS geom_json, ez.value_eur_m2::float AS value, ez.zone_name, ez.usage_type, ez.usage_detail, ez.dev_state, ez.stichtag, ( SELECT ROUND( count(*) FILTER (WHERE nearby.value_eur_m2 <= ez.value_eur_m2) * 100.0 / NULLIF(count(*), 0) )::int FROM nearby ) AS percentile_rank, (SELECT count(*)::int FROM nearby) AS nearby_count FROM estate_value_zones ez, pt, latest_year WHERE ST_Within(pt.geom, ez.geom) AND (ez.year IS NULL OR ez.year = latest_year.yr) LIMIT 1 `), // Score-based percentile: find all zones in the city, attach each zone's // accessibility score (composite = average across categories for the // nearest grid point), then compute the percentile of this zone's value // among zones with a similar accessibility score (±0.1 band). hasScoreParams ? Promise.resolve(sql<{ score_percentile_rank: number | null; similar_count: number; }[]>` WITH pt AS ( SELECT ST_SetSRID(ST_Point(${lng}, ${lat}), 4326) AS geom ), latest_year AS ( SELECT MAX(year) AS yr FROM estate_value_zones WHERE source = 'boris-ni' AND city_slug = ${cityParam} ), clicked_zone AS ( SELECT ez.value_eur_m2 FROM estate_value_zones ez, pt, latest_year WHERE ST_Within(pt.geom, ez.geom) AND (ez.year IS NULL OR ez.year = latest_year.yr) LIMIT 1 ), clicked_gp_score AS ( SELECT AVG(gs.score) AS composite_score FROM grid_scores gs WHERE gs.grid_point_id = ( SELECT gp.id FROM grid_points gp, pt WHERE gp.city_slug = ${cityParam} ORDER BY gp.geom <-> pt.geom LIMIT 1 ) AND gs.travel_mode = ${modeParam} AND gs.threshold_min = ${thresholdNum} AND gs.profile = ${profileParam} ), zone_scores AS ( SELECT ez.value_eur_m2, nearest.composite_score FROM estate_value_zones ez, latest_year JOIN LATERAL ( SELECT AVG(gs.score) AS composite_score FROM grid_scores gs WHERE gs.grid_point_id = ( SELECT gp.id FROM grid_points gp WHERE gp.city_slug = ${cityParam} ORDER BY gp.geom <-> ST_PointOnSurface(ez.geom) LIMIT 1 ) AND gs.travel_mode = ${modeParam} AND gs.threshold_min = ${thresholdNum} AND gs.profile = ${profileParam} ) nearest ON true WHERE ez.value_eur_m2 IS NOT NULL AND ez.city_slug = ${cityParam} AND (ez.year IS NULL OR ez.year = latest_year.yr) ), peer_zones AS ( SELECT value_eur_m2 FROM zone_scores WHERE ABS(composite_score - (SELECT composite_score FROM clicked_gp_score)) <= 0.1 ) SELECT ROUND( count(*) FILTER ( WHERE value_eur_m2 <= (SELECT value_eur_m2 FROM clicked_zone) ) * 100.0 / NULLIF(count(*), 0) )::int AS score_percentile_rank, count(*)::int AS similar_count FROM peer_zones `) : Promise.resolve([] as { score_percentile_rank: number | null; similar_count: number }[]), ]); const scoreRow = scoreRows[0] ?? null; const features = mainRows.map((row) => ({ type: "Feature", geometry: JSON.parse(row.geom_json), properties: { value: row.value, zoneName: row.zone_name, usageType: row.usage_type, usageDetail: row.usage_detail, devState: row.dev_state, stichtag: row.stichtag, percentileRank: row.percentile_rank, nearbyCount: row.nearby_count, scorePercentileRank: scoreRow?.score_percentile_rank ?? null, similarCount: scoreRow?.similar_count ?? 0, }, })); return NextResponse.json( { type: "FeatureCollection", features }, { headers: { "Cache-Control": "no-store" } }, ); } // ── Bbox query: ?bbox=minLng,minLat,maxLng,maxLat&city=slug ─────────────── const bboxParam = searchParams.get("bbox"); if (!bboxParam) { return NextResponse.json({ error: "bbox or lat/lng required" }, { status: 400 }); } const parts = bboxParam.split(",").map(Number); if (parts.length !== 4 || parts.some(isNaN)) { return NextResponse.json({ error: "invalid bbox" }, { status: 400 }); } const [minLng, minLat, maxLng, maxLat] = parts; const cityParam = searchParams.get("city"); const rows = await Promise.resolve(sql<{ geom_json: string; value: number | null; zone_name: string | null; usage_type: string | null; usage_detail: string | null; dev_state: string | null; stichtag: string | null; }[]>` SELECT ST_AsGeoJSON(ez.geom) AS geom_json, ez.value_eur_m2::float AS value, ez.zone_name, ez.usage_type, ez.usage_detail, ez.dev_state, ez.stichtag FROM estate_value_zones ez ${cityParam ? sql` JOIN LATERAL ( SELECT MAX(year) AS yr FROM estate_value_zones WHERE source = 'boris-ni' AND city_slug = ${cityParam} ) latest_year ON (ez.year IS NULL OR ez.year = latest_year.yr) WHERE ez.city_slug = ${cityParam} AND ST_Intersects(ez.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))` : sql` WHERE ST_Intersects(ez.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))` } LIMIT 5000 `); const features = rows.map((row) => ({ type: "Feature", geometry: JSON.parse(row.geom_json), properties: { value: row.value, zoneName: row.zone_name, usageType: row.usage_type, usageDetail: row.usage_detail, devState: row.dev_state, stichtag: row.stichtag, }, })); return NextResponse.json( { type: "FeatureCollection", features }, { headers: { "Cache-Control": "public, max-age=3600" } }, ); }