243 lines
8.9 KiB
TypeScript
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" } },
|
|
);
|
|
}
|