fifteen/apps/web/app/api/estate-value/route.ts

243 lines
8.9 KiB
TypeScript

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