feat: add hidden gem mode and some fixes
This commit is contained in:
parent
292598f0bd
commit
655d795a02
18 changed files with 1500 additions and 220 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { Job } from "bullmq";
|
import { Job } from "bullmq";
|
||||||
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
|
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
|
||||||
import type { PipelineJobData, JobProgress } from "@/lib/queue";
|
import type { PipelineJobData, JobProgress, ComputeScoresJobData } from "@/lib/queue";
|
||||||
import type { SSEEvent } from "@transportationer/shared";
|
import type { SSEEvent } from "@transportationer/shared";
|
||||||
|
import { CATEGORY_IDS } from "@transportationer/shared";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|
@ -56,11 +57,45 @@ export async function GET(
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
// 1. Find the currently active stage across both queues.
|
// 1. Fetch active jobs and waiting-children jobs in parallel.
|
||||||
const [pipelineActive, valhallaActive] = await Promise.all([
|
const [pipelineActive, valhallaActive, waitingChildren] = await Promise.all([
|
||||||
queue.getActive(0, 100),
|
queue.getActive(0, 100),
|
||||||
valhallaQueue.getActive(0, 100),
|
valhallaQueue.getActive(0, 100),
|
||||||
|
queue.getWaitingChildren(0, 100),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 1a. Parallel routing phase: compute-scores is waiting for its routing
|
||||||
|
// children to finish. Report aggregate progress instead of one job's pct.
|
||||||
|
const csWaiting = waitingChildren.find(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-scores",
|
||||||
|
);
|
||||||
|
if (csWaiting) {
|
||||||
|
const csData = csWaiting.data as ComputeScoresJobData;
|
||||||
|
const totalRoutingJobs = csData.modes.length * CATEGORY_IDS.length;
|
||||||
|
|
||||||
|
// Count jobs that haven't finished yet (active or still waiting in queue)
|
||||||
|
const pipelineWaiting = await queue.getWaiting(0, 200);
|
||||||
|
const stillActive = pipelineActive.filter(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing",
|
||||||
|
).length;
|
||||||
|
const stillWaiting = pipelineWaiting.filter(
|
||||||
|
(j) => j.data.citySlug === citySlug && j.data.type === "compute-routing",
|
||||||
|
).length;
|
||||||
|
const completedCount = Math.max(0, totalRoutingJobs - stillActive - stillWaiting);
|
||||||
|
const pct = totalRoutingJobs > 0
|
||||||
|
? Math.round((completedCount / totalRoutingJobs) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
enqueue({
|
||||||
|
type: "progress",
|
||||||
|
stage: "Computing scores",
|
||||||
|
pct,
|
||||||
|
message: `${completedCount} / ${totalRoutingJobs} routing jobs`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Sequential phase: report whichever single job is currently active.
|
||||||
const activeJob = [...pipelineActive, ...valhallaActive].find(
|
const activeJob = [...pipelineActive, ...valhallaActive].find(
|
||||||
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
|
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
11
apps/web/app/api/boris/route.ts
Normal file
11
apps/web/app/api/boris/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Renamed to /api/estate-value — this file can be deleted.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET(request: NextRequest) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
url.pathname = "/api/estate-value";
|
||||||
|
return NextResponse.redirect(url, 301);
|
||||||
|
}
|
||||||
213
apps/web/app/api/estate-value/route.ts
Normal file
213
apps/web/app/api/estate-value/route.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
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
|
||||||
|
),
|
||||||
|
nearby AS (
|
||||||
|
SELECT value_eur_m2
|
||||||
|
FROM estate_value_zones, pt
|
||||||
|
WHERE value_eur_m2 IS NOT NULL
|
||||||
|
AND ST_DWithin(estate_value_zones.geom::geography, pt.geom::geography, 5000)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
WHERE ST_Within(pt.geom, ez.geom)
|
||||||
|
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
|
||||||
|
),
|
||||||
|
clicked_zone AS (
|
||||||
|
SELECT value_eur_m2
|
||||||
|
FROM estate_value_zones, pt
|
||||||
|
WHERE ST_Within(pt.geom, estate_value_zones.geom)
|
||||||
|
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
|
||||||
|
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}
|
||||||
|
),
|
||||||
|
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 ─────────────────────────
|
||||||
|
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 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(geom) AS geom_json,
|
||||||
|
value_eur_m2::float AS value,
|
||||||
|
zone_name, usage_type, usage_detail, dev_state, stichtag
|
||||||
|
FROM estate_value_zones
|
||||||
|
WHERE ST_Intersects(
|
||||||
|
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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/app/api/tiles/hidden-gems/[...tile]/route.ts
Normal file
66
apps/web/app/api/tiles/hidden-gems/[...tile]/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/lib/db";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
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 city = req.nextUrl.searchParams.get("city") ?? "";
|
||||||
|
if (!city) return new NextResponse("Missing city", { status: 400 });
|
||||||
|
|
||||||
|
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, 'hidden-gems', 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,
|
||||||
|
ROUND(gp.hidden_gem_score * 100)::int AS score
|
||||||
|
FROM grid_points gp
|
||||||
|
CROSS JOIN envelope e
|
||||||
|
CROSS JOIN city_info ci
|
||||||
|
WHERE gp.city_slug = ${city}
|
||||||
|
AND gp.hidden_gem_score IS NOT NULL
|
||||||
|
AND ST_Intersects(
|
||||||
|
ST_Transform(gp.geom, 3857),
|
||||||
|
ST_Expand(e.env, ci.resolution_m::float / 2)
|
||||||
|
)
|
||||||
|
) 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=3600",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tiles/hidden-gems] Error:", err);
|
||||||
|
return new NextResponse("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,12 +49,33 @@ export default function HomePage() {
|
||||||
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
|
const [pinLocation, setPinLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
const [pinData, setPinData] = useState<LocationScoreData | null>(null);
|
const [pinData, setPinData] = useState<LocationScoreData | null>(null);
|
||||||
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
|
const [pinAddress, setPinAddress] = useState<string | undefined>(undefined);
|
||||||
|
const [pinEstateValue, setPinEstateValue] = useState<number | null>(null);
|
||||||
|
const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null);
|
||||||
|
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
|
||||||
|
|
||||||
// Overlay mode: isochrone (new default) or relative heatmap
|
// Overlay mode: isochrone (new default) or relative heatmap
|
||||||
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
|
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
|
||||||
const [isochroneData, setIsochroneData] = useState<object | null>(null);
|
const [isochroneData, setIsochroneData] = useState<object | null>(null);
|
||||||
const [isochroneLoading, setIsochroneLoading] = useState(false);
|
const [isochroneLoading, setIsochroneLoading] = useState(false);
|
||||||
|
|
||||||
|
// Base overlay: which layer to show when no pin is active
|
||||||
|
const [baseOverlay, setBaseOverlay] = useState<"accessibility" | "estate-value" | "hidden-gem">("accessibility");
|
||||||
|
|
||||||
|
// Derived city data — used in effects below so must be declared before them
|
||||||
|
const selectedCityData = cities.find((c) => c.slug === selectedCity);
|
||||||
|
const cityBbox = selectedCityData?.bbox;
|
||||||
|
const estateValueAvailable =
|
||||||
|
cityBbox != null &&
|
||||||
|
cityBbox[0] < 11.779 &&
|
||||||
|
cityBbox[2] > 6.526 &&
|
||||||
|
cityBbox[1] < 54.033 &&
|
||||||
|
cityBbox[3] > 51.197;
|
||||||
|
|
||||||
|
// Reset base overlay when city changes (availability depends on city)
|
||||||
|
useEffect(() => {
|
||||||
|
setBaseOverlay("accessibility");
|
||||||
|
}, [selectedCity]);
|
||||||
|
|
||||||
// Load city list
|
// Load city list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/cities")
|
fetch("/api/cities")
|
||||||
|
|
@ -79,7 +100,13 @@ export default function HomePage() {
|
||||||
|
|
||||||
// Fetch location score + reverse geocode when pin changes
|
// Fetch location score + reverse geocode when pin changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pinLocation || !selectedCity) return;
|
if (!pinLocation || !selectedCity) {
|
||||||
|
setPinData(null);
|
||||||
|
setPinAddress(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat: String(pinLocation.lat),
|
lat: String(pinLocation.lat),
|
||||||
|
|
@ -101,13 +128,59 @@ export default function HomePage() {
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
])
|
])
|
||||||
.then(([scoreData, address]) => {
|
.then(([scoreData, address]) => {
|
||||||
if (scoreData?.error) return;
|
if (cancelled) return;
|
||||||
|
if (scoreData?.error) {
|
||||||
|
// No grid data for this location — clear the pin so the skeleton doesn't persist.
|
||||||
|
setPinLocation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPinData(scoreData as LocationScoreData);
|
setPinData(scoreData as LocationScoreData);
|
||||||
setPinAddress(address);
|
setPinAddress(address);
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(() => { if (!cancelled) setPinLocation(null); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [pinLocation, selectedCity, mode, threshold, profile]);
|
}, [pinLocation, selectedCity, mode, threshold, profile]);
|
||||||
|
|
||||||
|
// Fetch estate value + percentile ratings for the clicked location
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pinLocation || !estateValueAvailable || !selectedCity) {
|
||||||
|
setPinEstateValue(null);
|
||||||
|
setPinEstatePercentile(null);
|
||||||
|
setPinEstateScorePercentile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { lat, lng } = pinLocation;
|
||||||
|
let cancelled = false;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
lat: String(lat),
|
||||||
|
lng: String(lng),
|
||||||
|
city: selectedCity,
|
||||||
|
mode,
|
||||||
|
threshold: String(threshold),
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
fetch(`/api/estate-value?${params}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((geojson) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const props = geojson?.features?.[0]?.properties;
|
||||||
|
setPinEstateValue(typeof props?.value === "number" ? props.value : null);
|
||||||
|
setPinEstatePercentile(typeof props?.percentileRank === "number" ? props.percentileRank : null);
|
||||||
|
setPinEstateScorePercentile(typeof props?.scorePercentileRank === "number" ? props.scorePercentileRank : null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPinEstateValue(null);
|
||||||
|
setPinEstatePercentile(null);
|
||||||
|
setPinEstateScorePercentile(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pinLocation, estateValueAvailable, selectedCity, mode, threshold, profile]);
|
||||||
|
|
||||||
// Fetch isochrone when in isochrone mode with an active pin
|
// Fetch isochrone when in isochrone mode with an active pin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pinLocation || overlayMode !== "isochrone") {
|
if (!pinLocation || overlayMode !== "isochrone") {
|
||||||
|
|
@ -115,6 +188,7 @@ export default function HomePage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
setIsochroneLoading(true);
|
setIsochroneLoading(true);
|
||||||
setIsochroneData(null);
|
setIsochroneData(null);
|
||||||
|
|
||||||
|
|
@ -130,14 +204,20 @@ export default function HomePage() {
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
// Valhalla may return 200 OK with error_code (not error) for unroutable locations.
|
// Valhalla may return 200 OK with error_code (not error) for unroutable locations.
|
||||||
// Only accept valid FeatureCollections.
|
// Only accept valid FeatureCollections.
|
||||||
if (!data.error && !data.error_code && Array.isArray(data.features)) {
|
if (!data.error && !data.error_code && Array.isArray(data.features)) {
|
||||||
setIsochroneData(data);
|
setIsochroneData(data);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(() => {})
|
||||||
.finally(() => setIsochroneLoading(false));
|
.finally(() => { if (!cancelled) setIsochroneLoading(false); });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
setIsochroneLoading(false);
|
||||||
|
};
|
||||||
}, [pinLocation, overlayMode, mode, threshold]);
|
}, [pinLocation, overlayMode, mode, threshold]);
|
||||||
|
|
||||||
function handleProfileChange(newProfile: ProfileId) {
|
function handleProfileChange(newProfile: ProfileId) {
|
||||||
|
|
@ -147,13 +227,19 @@ export default function HomePage() {
|
||||||
setPinLocation(null);
|
setPinLocation(null);
|
||||||
setPinData(null);
|
setPinData(null);
|
||||||
setPinAddress(undefined);
|
setPinAddress(undefined);
|
||||||
|
setPinEstateValue(null);
|
||||||
|
setPinEstatePercentile(null);
|
||||||
|
setPinEstateScorePercentile(null);
|
||||||
setIsochroneData(null);
|
setIsochroneData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLocationClick(lat: number, lng: number) {
|
function handleLocationClick(lat: number, lng: number, estateValue: number | null) {
|
||||||
setPinLocation({ lat, lng });
|
setPinLocation({ lat, lng });
|
||||||
setPinData(null);
|
setPinData(null);
|
||||||
setPinAddress(undefined);
|
setPinAddress(undefined);
|
||||||
|
setPinEstateValue(estateValue);
|
||||||
|
setPinEstatePercentile(null);
|
||||||
|
setPinEstateScorePercentile(null);
|
||||||
setIsochroneData(null);
|
setIsochroneData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,10 +247,12 @@ export default function HomePage() {
|
||||||
setPinLocation(null);
|
setPinLocation(null);
|
||||||
setPinData(null);
|
setPinData(null);
|
||||||
setPinAddress(undefined);
|
setPinAddress(undefined);
|
||||||
|
setPinEstateValue(null);
|
||||||
|
setPinEstatePercentile(null);
|
||||||
|
setPinEstateScorePercentile(null);
|
||||||
setIsochroneData(null);
|
setIsochroneData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCityData = cities.find((c) => c.slug === selectedCity);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
|
|
@ -184,11 +272,14 @@ export default function HomePage() {
|
||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
weights={weights}
|
weights={weights}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
|
baseOverlay={baseOverlay}
|
||||||
|
estateValueAvailable={estateValueAvailable}
|
||||||
onProfileChange={handleProfileChange}
|
onProfileChange={handleProfileChange}
|
||||||
onModeChange={setMode}
|
onModeChange={setMode}
|
||||||
onThresholdChange={setThreshold}
|
onThresholdChange={setThreshold}
|
||||||
onWeightChange={(cat, w) => setWeights((prev) => ({ ...prev, [cat]: w }))}
|
onWeightChange={(cat, w) => setWeights((prev) => ({ ...prev, [cat]: w }))}
|
||||||
onCategoryChange={setActiveCategory}
|
onCategoryChange={setActiveCategory}
|
||||||
|
onBaseOverlayChange={setBaseOverlay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|
@ -219,21 +310,57 @@ export default function HomePage() {
|
||||||
overlayMode === "relative" ? (pinData?.categoryScores ?? null) : null
|
overlayMode === "relative" ? (pinData?.categoryScores ?? null) : null
|
||||||
}
|
}
|
||||||
isochrones={overlayMode === "isochrone" ? isochroneData : null}
|
isochrones={overlayMode === "isochrone" ? isochroneData : null}
|
||||||
|
baseOverlay={baseOverlay}
|
||||||
onLocationClick={handleLocationClick}
|
onLocationClick={handleLocationClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MapLegend
|
<MapLegend
|
||||||
overlayMode={overlayMode}
|
overlayMode={overlayMode}
|
||||||
|
baseOverlay={baseOverlay}
|
||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
hasPinData={!!pinData}
|
hasPinData={!!pinData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pinLocation && !pinData && (
|
||||||
|
<div className="absolute bottom-8 right-4 z-20 bg-white rounded-xl shadow-lg border border-gray-100 w-72 p-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePinClose}
|
||||||
|
className="absolute top-3 right-3 text-gray-300 hover:text-gray-500 text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<div className="w-14 h-14 bg-gray-200 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 pt-1 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="h-2.5 bg-gray-200 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-7 bg-gray-100 rounded-lg mb-3.5" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<div className="h-2.5 bg-gray-200 rounded w-24" />
|
||||||
|
<div className="h-2.5 bg-gray-200 rounded w-14" />
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-100 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pinData && (
|
{pinData && (
|
||||||
<LocationScorePanel
|
<LocationScorePanel
|
||||||
data={pinData}
|
data={pinData}
|
||||||
weights={weights}
|
weights={weights}
|
||||||
address={pinAddress}
|
address={pinAddress}
|
||||||
|
estateValue={pinEstateValue}
|
||||||
|
estatePercentile={pinEstatePercentile}
|
||||||
|
estateScorePercentile={pinEstateScorePercentile}
|
||||||
overlayMode={overlayMode}
|
overlayMode={overlayMode}
|
||||||
isochroneLoading={isochroneLoading}
|
isochroneLoading={isochroneLoading}
|
||||||
onOverlayModeChange={setOverlayMode}
|
onOverlayModeChange={setOverlayMode}
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,22 @@ const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> =
|
||||||
|
|
||||||
const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
|
const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
|
||||||
|
|
||||||
|
type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
|
||||||
|
|
||||||
interface ControlPanelProps {
|
interface ControlPanelProps {
|
||||||
profile: ProfileId;
|
profile: ProfileId;
|
||||||
mode: TravelMode;
|
mode: TravelMode;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
weights: Record<CategoryId, number>;
|
weights: Record<CategoryId, number>;
|
||||||
activeCategory: CategoryId | "composite";
|
activeCategory: CategoryId | "composite";
|
||||||
|
baseOverlay: BaseOverlay;
|
||||||
|
estateValueAvailable: boolean;
|
||||||
onProfileChange: (p: ProfileId) => void;
|
onProfileChange: (p: ProfileId) => void;
|
||||||
onModeChange: (m: TravelMode) => void;
|
onModeChange: (m: TravelMode) => void;
|
||||||
onThresholdChange: (t: number) => void;
|
onThresholdChange: (t: number) => void;
|
||||||
onWeightChange: (cat: CategoryId, w: number) => void;
|
onWeightChange: (cat: CategoryId, w: number) => void;
|
||||||
onCategoryChange: (cat: CategoryId | "composite") => void;
|
onCategoryChange: (cat: CategoryId | "composite") => void;
|
||||||
|
onBaseOverlayChange: (o: BaseOverlay) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ControlPanel({
|
export function ControlPanel({
|
||||||
|
|
@ -31,11 +36,14 @@ export function ControlPanel({
|
||||||
threshold,
|
threshold,
|
||||||
weights,
|
weights,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
|
baseOverlay,
|
||||||
|
estateValueAvailable,
|
||||||
onProfileChange,
|
onProfileChange,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onThresholdChange,
|
onThresholdChange,
|
||||||
onWeightChange,
|
onWeightChange,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
|
onBaseOverlayChange,
|
||||||
}: ControlPanelProps) {
|
}: ControlPanelProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-y-auto">
|
<aside className="w-72 shrink-0 bg-white border-r border-gray-200 flex flex-col overflow-y-auto">
|
||||||
|
|
@ -161,6 +169,77 @@ export function ControlPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Base overlay selector */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<p className="text-xs font-medium text-gray-600 mb-2 uppercase tracking-wide">
|
||||||
|
Map Overlay
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onBaseOverlayChange("accessibility")}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
|
||||||
|
baseOverlay === "accessibility"
|
||||||
|
? "bg-gray-900 text-white font-medium border-gray-900"
|
||||||
|
: "text-gray-600 hover:bg-gray-50 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full shrink-0 bg-gradient-to-r from-red-400 to-green-400" />
|
||||||
|
<span>Accessibility</span>
|
||||||
|
</button>
|
||||||
|
{estateValueAvailable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onBaseOverlayChange("estate-value")}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
|
||||||
|
baseOverlay === "estate-value"
|
||||||
|
? "bg-amber-100 text-amber-900 font-medium border-amber-300"
|
||||||
|
: "text-gray-600 hover:bg-gray-50 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${baseOverlay === "estate-value" ? "bg-amber-500" : "bg-gray-300"}`} />
|
||||||
|
<span>Land Value</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400">BORIS NI</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onBaseOverlayChange("hidden-gem")}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors border ${
|
||||||
|
baseOverlay === "hidden-gem"
|
||||||
|
? "bg-green-100 text-green-900 font-medium border-green-300"
|
||||||
|
: "text-gray-600 hover:bg-gray-50 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${baseOverlay === "hidden-gem" ? "bg-green-500" : "bg-gray-300"}`} />
|
||||||
|
<span>Hidden Gems</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400">BORIS NI</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{baseOverlay === "estate-value" && estateValueAvailable && (
|
||||||
|
<div className="mt-2 px-1">
|
||||||
|
<div className="h-2 rounded-full" style={{ background: "linear-gradient(to right, #ffffb2, #fecc5c, #fd8d3c, #f03b20, #bd0026)" }} />
|
||||||
|
<div className="flex justify-between mt-0.5 text-[9px] text-gray-400">
|
||||||
|
<span>0</span>
|
||||||
|
<span>100</span>
|
||||||
|
<span>300</span>
|
||||||
|
<span>700</span>
|
||||||
|
<span>1500+</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-[9px] text-gray-400 mt-0.5">€/m²</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{baseOverlay === "hidden-gem" && estateValueAvailable && (
|
||||||
|
<div className="mt-2 px-1">
|
||||||
|
<div className="h-2 rounded-full" style={{ background: "linear-gradient(to right, #d73027, #fee08b, #d9ef8b, #1a9850)" }} />
|
||||||
|
<div className="flex justify-between mt-0.5 text-[9px] text-gray-400">
|
||||||
|
<span>Poor</span>
|
||||||
|
<span>Gem score</span>
|
||||||
|
<span>High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Category weights */}
|
{/* Category weights */}
|
||||||
<div className="p-4 flex-1">
|
<div className="p-4 flex-1">
|
||||||
<p className="text-xs font-medium text-gray-600 mb-3 uppercase tracking-wide">
|
<p className="text-xs font-medium text-gray-600 mb-3 uppercase tracking-wide">
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,9 @@ export function LocationScorePanel({
|
||||||
data,
|
data,
|
||||||
weights,
|
weights,
|
||||||
address,
|
address,
|
||||||
|
estateValue,
|
||||||
|
estatePercentile,
|
||||||
|
estateScorePercentile,
|
||||||
overlayMode,
|
overlayMode,
|
||||||
isochroneLoading,
|
isochroneLoading,
|
||||||
onOverlayModeChange,
|
onOverlayModeChange,
|
||||||
|
|
@ -120,6 +123,11 @@ export function LocationScorePanel({
|
||||||
data: LocationScoreData;
|
data: LocationScoreData;
|
||||||
weights: Weights;
|
weights: Weights;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
estateValue?: number | null;
|
||||||
|
/** % of zones within 5 km with a lower value (0–100). */
|
||||||
|
estatePercentile?: number | null;
|
||||||
|
/** % of zones with similar accessibility score that have a lower value (0–100). */
|
||||||
|
estateScorePercentile?: number | null;
|
||||||
overlayMode: OverlayMode;
|
overlayMode: OverlayMode;
|
||||||
isochroneLoading: boolean;
|
isochroneLoading: boolean;
|
||||||
onOverlayModeChange: (mode: OverlayMode) => void;
|
onOverlayModeChange: (mode: OverlayMode) => void;
|
||||||
|
|
@ -136,6 +144,28 @@ export function LocationScorePanel({
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-5xl font-bold leading-none ${gradeColor(g)}`}>{g}</div>
|
<div className={`text-5xl font-bold leading-none ${gradeColor(g)}`}>{g}</div>
|
||||||
<div className="text-xs text-gray-400 mt-1">{Math.round(composite * 100)} / 100</div>
|
<div className="text-xs text-gray-400 mt-1">{Math.round(composite * 100)} / 100</div>
|
||||||
|
{estateValue != null && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 border border-amber-200">
|
||||||
|
<span className="text-amber-700 font-semibold text-xs">{estateValue.toLocaleString("de-DE")} €/m²</span>
|
||||||
|
<span className="text-amber-400 text-[10px]">land value</span>
|
||||||
|
</div>
|
||||||
|
{(estatePercentile != null || estateScorePercentile != null) && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-0.5">
|
||||||
|
{estatePercentile != null && (
|
||||||
|
<div className="text-[10px] text-amber-600">
|
||||||
|
Pricier than {estatePercentile}% of zones · 5 km radius
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{estateScorePercentile != null && (
|
||||||
|
<div className="text-[10px] text-amber-500">
|
||||||
|
Pricier than {estateScorePercentile}% · similar accessibility
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{address && (
|
{address && (
|
||||||
<div className="text-xs text-gray-400 mt-1 truncate max-w-[200px]" title={address}>
|
<div className="text-xs text-gray-400 mt-1 truncate max-w-[200px]" title={address}>
|
||||||
{address}
|
{address}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { OverlayMode } from "./location-score-panel";
|
import type { OverlayMode } from "./location-score-panel";
|
||||||
|
import type { BaseOverlay } from "./map-view";
|
||||||
|
|
||||||
interface MapLegendProps {
|
interface MapLegendProps {
|
||||||
overlayMode: OverlayMode;
|
overlayMode: OverlayMode;
|
||||||
|
baseOverlay: BaseOverlay;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
hasPinData: boolean;
|
hasPinData: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +23,7 @@ function gradientCss(stops: [number, string][]): string {
|
||||||
return `linear-gradient(to right, ${stops.map(([p, c]) => `${c} ${p * 100}%`).join(", ")})`;
|
return `linear-gradient(to right, ${stops.map(([p, c]) => `${c} ${p * 100}%`).join(", ")})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapLegend({ overlayMode, threshold, hasPinData }: MapLegendProps) {
|
export function MapLegend({ overlayMode, baseOverlay, threshold, hasPinData }: MapLegendProps) {
|
||||||
if (overlayMode === "isochrone" && hasPinData) {
|
if (overlayMode === "isochrone" && hasPinData) {
|
||||||
// Travel-time legend: green (near) → red (far)
|
// Travel-time legend: green (near) → red (far)
|
||||||
const stops: [number, string][] = [
|
const stops: [number, string][] = [
|
||||||
|
|
@ -67,6 +69,53 @@ export function MapLegend({ overlayMode, threshold, hasPinData }: MapLegendProps
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hidden gem score
|
||||||
|
if (baseOverlay === "hidden-gem" && !hasPinData) {
|
||||||
|
const stops: [number, string][] = [
|
||||||
|
[0, "#d73027"],
|
||||||
|
[0.4, "#fee08b"],
|
||||||
|
[0.7, "#d9ef8b"],
|
||||||
|
[1, "#1a9850"],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
|
||||||
|
<div className="font-medium mb-1.5">Hidden Gem Score</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="shrink-0">Poor value</span>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-28 rounded-full"
|
||||||
|
style={{ background: gradientCss(stops) }}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0">High value</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estate value (land price)
|
||||||
|
if (baseOverlay === "estate-value" && !hasPinData) {
|
||||||
|
const stops: [number, string][] = [
|
||||||
|
[0, "#ffffb2"],
|
||||||
|
[0.07, "#fecc5c"],
|
||||||
|
[0.2, "#fd8d3c"],
|
||||||
|
[0.47, "#f03b20"],
|
||||||
|
[1, "#bd0026"],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
|
||||||
|
<div className="font-medium mb-1.5">Land Value</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="shrink-0">0</span>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-28 rounded-full"
|
||||||
|
style={{ background: gradientCss(stops) }}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0">1500+ €/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Default: absolute accessibility score
|
// Default: absolute accessibility score
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
|
<div className="absolute bottom-8 left-4 z-20 bg-white/90 backdrop-blur-sm rounded-lg shadow border border-gray-100 px-3 py-2 text-xs text-gray-600">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as maplibregl from "maplibre-gl";
|
||||||
|
import { Protocol } from "pmtiles";
|
||||||
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
|
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
|
||||||
|
|
||||||
type Weights = Record<CategoryId, number>;
|
type Weights = Record<CategoryId, number>;
|
||||||
|
|
||||||
|
export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
|
||||||
|
|
||||||
export interface MapViewProps {
|
export interface MapViewProps {
|
||||||
citySlug: string;
|
citySlug: string;
|
||||||
cityBbox: [number, number, number, number];
|
cityBbox: [number, number, number, number];
|
||||||
|
|
@ -18,7 +22,9 @@ export interface MapViewProps {
|
||||||
pinCategoryScores?: Record<CategoryId, number> | null;
|
pinCategoryScores?: Record<CategoryId, number> | null;
|
||||||
/** Set in isochrone mode: GeoJSON FeatureCollection from Valhalla. */
|
/** Set in isochrone mode: GeoJSON FeatureCollection from Valhalla. */
|
||||||
isochrones?: object | null;
|
isochrones?: object | null;
|
||||||
onLocationClick?: (lat: number, lng: number) => void;
|
/** Which base overlay to show (accessibility grid, estate value, or hidden gem). */
|
||||||
|
baseOverlay?: BaseOverlay;
|
||||||
|
onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Red → yellow → green score ramp
|
// Red → yellow → green score ramp
|
||||||
|
|
@ -66,7 +72,6 @@ function makeRelativeColorExpr(
|
||||||
pinScore = pinCategoryScores[cat] ?? 0;
|
pinScore = pinCategoryScores[cat] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diverging: negative = worse than pin (red), positive = better (green)
|
|
||||||
return [
|
return [
|
||||||
"interpolate", ["linear"], ["-", scoreExpr, pinScore],
|
"interpolate", ["linear"], ["-", scoreExpr, pinScore],
|
||||||
-0.5, "#d73027",
|
-0.5, "#d73027",
|
||||||
|
|
@ -78,16 +83,52 @@ function makeRelativeColorExpr(
|
||||||
}
|
}
|
||||||
|
|
||||||
function tileUrl(city: string, mode: string, threshold: number, profile: string) {
|
function tileUrl(city: string, mode: string, threshold: number, profile: string) {
|
||||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
return `${window.location.origin}/api/tiles/grid/{z}/{x}/{y}?city=${encodeURIComponent(city)}&mode=${mode}&threshold=${threshold}&profile=${profile}`;
|
||||||
return `${origin}/api/tiles/grid/{z}/{x}/{y}?city=${encodeURIComponent(city)}&mode=${mode}&threshold=${threshold}&profile=${profile}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove isochrone layer/source if they exist. */
|
function removeIsochroneLayers(map: maplibregl.Map) {
|
||||||
function removeIsochroneLayers(map: import("maplibre-gl").Map) {
|
|
||||||
if (map.getLayer("isochrone-fill")) map.removeLayer("isochrone-fill");
|
if (map.getLayer("isochrone-fill")) map.removeLayer("isochrone-fill");
|
||||||
if (map.getSource("isochrone")) map.removeSource("isochrone");
|
if (map.getSource("isochrone")) map.removeSource("isochrone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeEstateValueLayers(map: maplibregl.Map) {
|
||||||
|
if (map.getLayer("estate-value-outline")) map.removeLayer("estate-value-outline");
|
||||||
|
if (map.getLayer("estate-value-fill")) map.removeLayer("estate-value-fill");
|
||||||
|
if (map.getSource("estate-value-zones")) map.removeSource("estate-value-zones");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHiddenGemLayers(map: maplibregl.Map) {
|
||||||
|
if (map.getLayer("hidden-gems-fill")) map.removeLayer("hidden-gems-fill");
|
||||||
|
if (map.getSource("hidden-gems-tiles")) map.removeSource("hidden-gems-tiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEstateValuePopupHtml(props: Record<string, unknown>): string {
|
||||||
|
const value = props.value != null ? `${props.value} €/m²` : "–";
|
||||||
|
const usageLabels: Record<string, string> = {
|
||||||
|
W: "Residential", G: "Commercial", LF: "Agricultural",
|
||||||
|
SF: "Special Use", B: "Mixed", GIF: "Mixed Infill",
|
||||||
|
};
|
||||||
|
const detailLabels: Record<string, string> = {
|
||||||
|
EFH: "Single-family", ZFH: "Two-family", MFH: "Multi-family",
|
||||||
|
RH: "Terraced", GW: "Mixed-use",
|
||||||
|
};
|
||||||
|
const usage = props.usageType
|
||||||
|
? (usageLabels[props.usageType as string] ?? props.usageType as string)
|
||||||
|
: null;
|
||||||
|
const detail = props.usageDetail
|
||||||
|
? (detailLabels[props.usageDetail as string] ?? props.usageDetail as string)
|
||||||
|
: null;
|
||||||
|
const zone = props.zoneName as string | null;
|
||||||
|
const stichtag = props.stichtag as string | null;
|
||||||
|
|
||||||
|
return `<div style="font:13px/1.5 system-ui,sans-serif;min-width:140px">
|
||||||
|
<div style="font-size:18px;font-weight:700;color:#c0392b;margin-bottom:2px">${value}</div>
|
||||||
|
${usage ? `<div style="color:#444">${usage}${detail ? ` · ${detail}` : ""}</div>` : ""}
|
||||||
|
${zone ? `<div style="color:#777;font-size:11px;margin-top:2px">${zone}</div>` : ""}
|
||||||
|
${stichtag ? `<div style="color:#aaa;font-size:10px">${stichtag}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
citySlug,
|
citySlug,
|
||||||
cityBbox,
|
cityBbox,
|
||||||
|
|
@ -99,181 +140,350 @@ export function MapView({
|
||||||
pinLocation,
|
pinLocation,
|
||||||
pinCategoryScores,
|
pinCategoryScores,
|
||||||
isochrones,
|
isochrones,
|
||||||
|
baseOverlay = "accessibility",
|
||||||
onLocationClick,
|
onLocationClick,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const markerRef = useRef<import("maplibre-gl").Marker | null>(null);
|
const markerRef = useRef<maplibregl.Marker | null>(null);
|
||||||
|
const estateValuePopupRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
const hiddenGemPopupRef = useRef<maplibregl.Popup | null>(null);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
|
// Ref for estate-value event handlers so cleanup can call map.off() even after async fetch
|
||||||
|
const evHandlersRef = useRef<{
|
||||||
|
map: maplibregl.Map;
|
||||||
|
onEnter: () => void;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onMove: (e: any) => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Tracked as state so effects re-run once the map style finishes loading.
|
||||||
|
// Without this, effects that check isStyleLoaded() return early on first render
|
||||||
|
// and never reapply state after the async on("load") fires.
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Derive which layers should be visible
|
||||||
|
const showGrid = !isochrones && (baseOverlay === "accessibility" || !!pinCategoryScores);
|
||||||
|
const showEstateValue = !isochrones && !pinCategoryScores && baseOverlay === "estate-value";
|
||||||
|
const showHiddenGem = !isochrones && !pinCategoryScores && baseOverlay === "hidden-gem";
|
||||||
|
|
||||||
const stateRef = useRef({
|
const stateRef = useRef({
|
||||||
citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick,
|
citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, onLocationClick,
|
||||||
});
|
});
|
||||||
stateRef.current = { citySlug, profile, mode, threshold, activeCategory, weights, onLocationClick };
|
stateRef.current = { citySlug, cityBbox, profile, mode, threshold, activeCategory, weights, onLocationClick };
|
||||||
|
|
||||||
// Update heatmap paint when category, weights, or pin scores change
|
// ── Update heatmap paint when category / weights / pin scores change ─────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map?.isStyleLoaded() || !map.getLayer("grid-fill")) return;
|
if (!map?.getLayer("grid-fill")) return;
|
||||||
const colorExpr = pinCategoryScores
|
const colorExpr = pinCategoryScores
|
||||||
? makeRelativeColorExpr(activeCategory, weights, pinCategoryScores)
|
? makeRelativeColorExpr(activeCategory, weights, pinCategoryScores)
|
||||||
: makeColorExpr(activeCategory, weights);
|
: makeColorExpr(activeCategory, weights);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
map.setPaintProperty("grid-fill", "fill-color", colorExpr as any);
|
map.setPaintProperty("grid-fill", "fill-color", colorExpr as any);
|
||||||
}, [activeCategory, weights, pinCategoryScores]);
|
}, [mapLoaded, activeCategory, weights, pinCategoryScores]);
|
||||||
|
|
||||||
// Update tile source when city/mode/threshold/profile change
|
// ── Update tile source when city / mode / threshold / profile change ──────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map?.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const src = map.getSource("grid-tiles") as any;
|
const src = map.getSource("grid-tiles") as any;
|
||||||
if (src?.setTiles) src.setTiles([tileUrl(citySlug, mode, threshold, profile)]);
|
if (src?.setTiles) src.setTiles([tileUrl(citySlug, mode, threshold, profile)]);
|
||||||
}, [citySlug, mode, threshold, profile]);
|
}, [mapLoaded, citySlug, mode, threshold, profile]);
|
||||||
|
|
||||||
// Add/remove pin marker when pin location changes
|
// ── Pin marker ─────────────────────────────────────────────────────────────
|
||||||
|
// Markers are DOM elements — no need to wait for style to be loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
markerRef.current?.remove();
|
markerRef.current?.remove();
|
||||||
markerRef.current = null;
|
markerRef.current = null;
|
||||||
if (pinLocation) {
|
if (pinLocation) {
|
||||||
import("maplibre-gl").then(({ Marker }) => {
|
markerRef.current = new maplibregl.Marker({ color: "#2563eb" })
|
||||||
const marker = new Marker({ color: "#2563eb" })
|
.setLngLat([pinLocation.lng, pinLocation.lat])
|
||||||
.setLngLat([pinLocation.lng, pinLocation.lat])
|
.addTo(map);
|
||||||
.addTo(map);
|
|
||||||
markerRef.current = marker;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [pinLocation]);
|
}, [pinLocation]);
|
||||||
|
|
||||||
// Add/remove isochrone layer when isochrones data changes.
|
// ── Grid visibility ───────────────────────────────────────────────────────
|
||||||
// The grid-fill layer is hidden while isochrones are shown so only one
|
|
||||||
// overlay is visible at a time.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map?.isStyleLoaded()) return;
|
if (!map?.getLayer("grid-fill")) return;
|
||||||
|
map.setLayoutProperty("grid-fill", "visibility", showGrid ? "visible" : "none");
|
||||||
|
}, [mapLoaded, showGrid]);
|
||||||
|
|
||||||
|
// ── Isochrone layer ───────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
|
const map = mapRef.current!;
|
||||||
|
|
||||||
removeIsochroneLayers(map);
|
removeIsochroneLayers(map);
|
||||||
|
|
||||||
if (!isochrones) {
|
if (!isochrones) return;
|
||||||
// Restore grid when leaving isochrone mode.
|
|
||||||
if (map.getLayer("grid-fill")) {
|
|
||||||
map.setLayoutProperty("grid-fill", "visibility", "visible");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the grid heatmap — the isochrone replaces it visually.
|
|
||||||
if (map.getLayer("grid-fill")) {
|
if (map.getLayer("grid-fill")) {
|
||||||
map.setLayoutProperty("grid-fill", "visibility", "none");
|
map.setLayoutProperty("grid-fill", "visibility", "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort largest contour first — smaller (inner, more accessible) polygons
|
|
||||||
// are drawn on top, so each pixel shows the color of the smallest contour
|
|
||||||
// that covers it (i.e. the fastest reachable zone wins visually).
|
|
||||||
const geojson = isochrones as { type: string; features: { properties: { contour: number } }[] };
|
const geojson = isochrones as { type: string; features: { properties: { contour: number } }[] };
|
||||||
if (!Array.isArray(geojson.features) || geojson.features.length === 0) {
|
if (!Array.isArray(geojson.features) || geojson.features.length === 0) return;
|
||||||
// Malformed response (e.g. Valhalla error body with no features) — restore grid.
|
|
||||||
if (map.getLayer("grid-fill")) map.setLayoutProperty("grid-fill", "visibility", "visible");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const contourValues = geojson.features.map((f) => f.properties.contour);
|
const contourValues = geojson.features.map((f) => f.properties.contour);
|
||||||
const maxContour = Math.max(...contourValues);
|
const maxContour = Math.max(...contourValues);
|
||||||
|
|
||||||
const sorted = {
|
const sorted = {
|
||||||
...geojson,
|
...geojson,
|
||||||
features: [...geojson.features].sort(
|
features: [...geojson.features].sort((a, b) => b.properties.contour - a.properties.contour),
|
||||||
(a, b) => b.properties.contour - a.properties.contour,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
map.addSource("isochrone", { type: "geojson", data: sorted as never });
|
||||||
map.addSource("isochrone", { type: "geojson", data: sorted as never });
|
map.addLayer({
|
||||||
// Color each zone using the same green→red ramp:
|
id: "isochrone-fill",
|
||||||
// small contour (close) = green, large contour (far) = red.
|
type: "fill",
|
||||||
map.addLayer({
|
source: "isochrone",
|
||||||
id: "isochrone-fill",
|
paint: {
|
||||||
type: "fill",
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
source: "isochrone",
|
"fill-color": ["interpolate", ["linear"], ["get", "contour"],
|
||||||
paint: {
|
0, "#1a9850",
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
maxContour * 0.5, "#fee08b",
|
||||||
"fill-color": ["interpolate", ["linear"], ["get", "contour"],
|
maxContour, "#d73027",
|
||||||
0, "#1a9850",
|
] as any,
|
||||||
maxContour * 0.5, "#fee08b",
|
"fill-opacity": 0.5,
|
||||||
maxContour, "#d73027",
|
"fill-outline-color": "rgba(0,0,0,0.1)",
|
||||||
] as any,
|
},
|
||||||
"fill-opacity": 0.65,
|
});
|
||||||
"fill-outline-color": "rgba(0,0,0,0.15)",
|
|
||||||
},
|
return () => { removeIsochroneLayers(map); };
|
||||||
});
|
}, [mapLoaded, isochrones]);
|
||||||
} catch (err) {
|
|
||||||
console.warn("[map-view] Error adding isochrone layer:", err);
|
// ── Hidden gem overlay ────────────────────────────────────────────────────
|
||||||
}
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
|
const map = mapRef.current!;
|
||||||
|
|
||||||
|
removeHiddenGemLayers(map);
|
||||||
|
hiddenGemPopupRef.current?.remove();
|
||||||
|
hiddenGemPopupRef.current = null;
|
||||||
|
|
||||||
|
if (!showHiddenGem) return;
|
||||||
|
|
||||||
|
const tileBase = `${window.location.origin}/api/tiles/hidden-gems/{z}/{x}/{y}?city=${encodeURIComponent(citySlug)}`;
|
||||||
|
|
||||||
|
map.addSource("hidden-gems-tiles", {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [tileBase],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 16,
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "hidden-gems-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "hidden-gems-tiles",
|
||||||
|
"source-layer": "hidden-gems",
|
||||||
|
paint: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
"fill-color": ["interpolate", ["linear"], ["coalesce", ["get", "score"], 0],
|
||||||
|
0, "#d73027",
|
||||||
|
40, "#fee08b",
|
||||||
|
70, "#d9ef8b",
|
||||||
|
100, "#1a9850",
|
||||||
|
] as any,
|
||||||
|
"fill-opacity": 0.5,
|
||||||
|
"fill-outline-color": "rgba(0,0,0,0.1)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" });
|
||||||
|
hiddenGemPopupRef.current = popup;
|
||||||
|
|
||||||
|
const onEnter = () => { map.getCanvas().style.cursor = "pointer"; };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const onMove = (e: any) => {
|
||||||
|
const feature = e.features?.[0];
|
||||||
|
if (!feature) return;
|
||||||
|
const score = feature.properties?.score as number | null;
|
||||||
|
popup.setLngLat(e.lngLat).setHTML(
|
||||||
|
`<div style="font:13px/1.5 system-ui,sans-serif">
|
||||||
|
<div style="font-size:16px;font-weight:700;color:#1a9850">${score != null ? score + "%" : "–"}</div>
|
||||||
|
<div style="color:#555;font-size:11px">Hidden gem score</div>
|
||||||
|
</div>`
|
||||||
|
).addTo(map);
|
||||||
|
};
|
||||||
|
const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); };
|
||||||
|
|
||||||
|
map.on("mouseenter", "hidden-gems-fill", onEnter);
|
||||||
|
map.on("mousemove", "hidden-gems-fill", onMove);
|
||||||
|
map.on("mouseleave", "hidden-gems-fill", onLeave);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const m = mapRef.current;
|
map.off("mouseenter", "hidden-gems-fill", onEnter);
|
||||||
if (!m?.isStyleLoaded()) return;
|
map.off("mousemove", "hidden-gems-fill", onMove);
|
||||||
removeIsochroneLayers(m);
|
map.off("mouseleave", "hidden-gems-fill", onLeave);
|
||||||
if (m.getLayer("grid-fill")) {
|
removeHiddenGemLayers(map);
|
||||||
m.setLayoutProperty("grid-fill", "visibility", "visible");
|
try { map.getCanvas().style.cursor = "crosshair"; } catch { /* map removed */ }
|
||||||
}
|
hiddenGemPopupRef.current?.remove();
|
||||||
|
hiddenGemPopupRef.current = null;
|
||||||
};
|
};
|
||||||
}, [isochrones]);
|
}, [mapLoaded, showHiddenGem, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Initialize map once on mount
|
// ── Estate value overlay ──────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapLoaded) return;
|
||||||
|
const map = mapRef.current!;
|
||||||
|
|
||||||
|
// Clean up any previously registered handlers before tearing down layers
|
||||||
|
if (evHandlersRef.current) {
|
||||||
|
const { map: m, onEnter, onMove, onLeave } = evHandlersRef.current;
|
||||||
|
m.off("mouseenter", "estate-value-fill", onEnter);
|
||||||
|
m.off("mousemove", "estate-value-fill", onMove);
|
||||||
|
m.off("mouseleave", "estate-value-fill", onLeave);
|
||||||
|
evHandlersRef.current = null;
|
||||||
|
}
|
||||||
|
removeEstateValueLayers(map);
|
||||||
|
estateValuePopupRef.current?.remove();
|
||||||
|
estateValuePopupRef.current = null;
|
||||||
|
|
||||||
|
if (!showEstateValue) return;
|
||||||
|
|
||||||
|
const [minLng, minLat, maxLng, maxLat] = stateRef.current.cityBbox;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetch(`/api/estate-value?bbox=${minLng},${minLat},${maxLng},${maxLat}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`estate-value API ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((geojson) => {
|
||||||
|
if (cancelled || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
map.addSource("estate-value-zones", { type: "geojson", data: geojson });
|
||||||
|
map.addLayer({
|
||||||
|
id: "estate-value-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "estate-value-zones",
|
||||||
|
paint: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
"fill-color": ["interpolate", ["linear"], ["coalesce", ["get", "value"], 0],
|
||||||
|
0, "#ffffb2",
|
||||||
|
100, "#fecc5c",
|
||||||
|
300, "#fd8d3c",
|
||||||
|
700, "#f03b20",
|
||||||
|
1500, "#bd0026",
|
||||||
|
] as any,
|
||||||
|
"fill-opacity": 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "estate-value-outline",
|
||||||
|
type: "line",
|
||||||
|
source: "estate-value-zones",
|
||||||
|
paint: { "line-color": "rgba(0,0,0,0.25)", "line-width": 0.6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "240px" });
|
||||||
|
estateValuePopupRef.current = popup;
|
||||||
|
|
||||||
|
const onEnter = () => { map.getCanvas().style.cursor = "pointer"; };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const onMove = (e: any) => {
|
||||||
|
const feature = e.features?.[0];
|
||||||
|
if (!feature) return;
|
||||||
|
popup.setLngLat(e.lngLat)
|
||||||
|
.setHTML(buildEstateValuePopupHtml(feature.properties as Record<string, unknown>))
|
||||||
|
.addTo(map);
|
||||||
|
};
|
||||||
|
const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); };
|
||||||
|
|
||||||
|
map.on("mouseenter", "estate-value-fill", onEnter);
|
||||||
|
map.on("mousemove", "estate-value-fill", onMove);
|
||||||
|
map.on("mouseleave", "estate-value-fill", onLeave);
|
||||||
|
evHandlersRef.current = { map, onEnter, onMove, onLeave };
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) console.warn("[map-view] estate-value fetch failed:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (evHandlersRef.current) {
|
||||||
|
const { map: m, onEnter, onMove, onLeave } = evHandlersRef.current;
|
||||||
|
m.off("mouseenter", "estate-value-fill", onEnter);
|
||||||
|
m.off("mousemove", "estate-value-fill", onMove);
|
||||||
|
m.off("mouseleave", "estate-value-fill", onLeave);
|
||||||
|
evHandlersRef.current = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
removeEstateValueLayers(map);
|
||||||
|
map.getCanvas().style.cursor = "crosshair";
|
||||||
|
}
|
||||||
|
} catch { /* map removed */ }
|
||||||
|
estateValuePopupRef.current?.remove();
|
||||||
|
estateValuePopupRef.current = null;
|
||||||
|
};
|
||||||
|
}, [mapLoaded, showEstateValue, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Initialize map (runs once on mount) ───────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountedRef.current || !containerRef.current) return;
|
if (mountedRef.current || !containerRef.current) return;
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
|
|
||||||
(async () => {
|
const protocol = new Protocol();
|
||||||
const mgl = await import("maplibre-gl");
|
maplibregl.addProtocol("pmtiles", protocol.tile);
|
||||||
const { Protocol } = await import("pmtiles");
|
|
||||||
|
|
||||||
const protocol = new Protocol();
|
const map = new maplibregl.Map({
|
||||||
mgl.addProtocol("pmtiles", protocol.tile);
|
container: containerRef.current,
|
||||||
|
style: "/tiles/style.json",
|
||||||
|
bounds: cityBbox,
|
||||||
|
fitBoundsOptions: { padding: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
const map = new mgl.Map({
|
mapRef.current = map;
|
||||||
container: containerRef.current!,
|
|
||||||
style: "/tiles/style.json",
|
map.on("load", () => {
|
||||||
bounds: cityBbox,
|
const { citySlug: city, profile: prof, mode: m, threshold: t, activeCategory: cat, weights: w } = stateRef.current;
|
||||||
fitBoundsOptions: { padding: 40 },
|
|
||||||
|
map.addSource("grid-tiles", {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [tileUrl(city, m, t, prof)],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 16,
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current = map;
|
map.addLayer({
|
||||||
|
id: "grid-fill",
|
||||||
map.on("load", () => {
|
type: "fill",
|
||||||
const { citySlug: city, profile: prof, mode: m, threshold: t, activeCategory: cat, weights: w } = stateRef.current;
|
source: "grid-tiles",
|
||||||
|
"source-layer": "grid",
|
||||||
map.addSource("grid-tiles", {
|
paint: {
|
||||||
type: "vector",
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
tiles: [tileUrl(city, m, t, prof)],
|
"fill-color": makeColorExpr(cat, w) as any,
|
||||||
minzoom: 0,
|
"fill-opacity": 0.5,
|
||||||
maxzoom: 16,
|
"fill-outline-color": "rgba(0,0,0,0.06)",
|
||||||
});
|
},
|
||||||
|
|
||||||
map.addLayer({
|
|
||||||
id: "grid-fill",
|
|
||||||
type: "fill",
|
|
||||||
source: "grid-tiles",
|
|
||||||
"source-layer": "grid",
|
|
||||||
paint: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
"fill-color": makeColorExpr(cat, w) as any,
|
|
||||||
"fill-opacity": 0.8,
|
|
||||||
"fill-outline-color": "rgba(0,0,0,0.06)",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
map.on("click", (e) => {
|
|
||||||
stateRef.current.onLocationClick?.(e.lngLat.lat, e.lngLat.lng);
|
|
||||||
});
|
|
||||||
|
|
||||||
map.getCanvas().style.cursor = "crosshair";
|
|
||||||
});
|
});
|
||||||
})();
|
|
||||||
|
map.on("click", (e) => {
|
||||||
|
const evFeatures = map.getLayer("estate-value-fill")
|
||||||
|
? map.queryRenderedFeatures(e.point, { layers: ["estate-value-fill"] })
|
||||||
|
: [];
|
||||||
|
const estateValue = (evFeatures[0]?.properties?.value as number | null) ?? null;
|
||||||
|
stateRef.current.onLocationClick?.(e.lngLat.lat, e.lngLat.lng, estateValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.getCanvas().style.cursor = "crosshair";
|
||||||
|
|
||||||
|
// Signal all reactive effects that the map is ready.
|
||||||
|
setMapLoaded(true);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
// Reset mapLoaded so effects re-run if the map is recreated (e.g. StrictMode).
|
||||||
|
setMapLoaded(false);
|
||||||
markerRef.current?.remove();
|
markerRef.current?.remove();
|
||||||
markerRef.current = null;
|
markerRef.current = null;
|
||||||
mapRef.current?.remove();
|
mapRef.current?.remove();
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,35 @@ export interface StageStatus {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Four logical UI stages that map to the actual (parallel) pipeline jobs.
|
||||||
|
// extract-pois and build-valhalla run concurrently — they share "Processing OSM"
|
||||||
|
// so the linear mark-prior-as-completed logic stays correct.
|
||||||
const STAGE_ORDER: Array<{ key: string; label: string }> = [
|
const STAGE_ORDER: Array<{ key: string; label: string }> = [
|
||||||
{ key: "Downloading PBF", label: "Download OSM data" },
|
{ key: "Downloading PBF", label: "Download OSM data" },
|
||||||
{ key: "Filtering OSM tags", label: "Filter & extract POIs" },
|
{ key: "Processing OSM", label: "Process OSM & build routes" },
|
||||||
{ key: "Importing to PostGIS", label: "Import to database" },
|
{ key: "Generating grid", label: "Generate analysis grid" },
|
||||||
{ key: "Building routing graph", label: "Build routing graph" },
|
{ key: "Computing scores", label: "Compute accessibility scores" },
|
||||||
{ key: "Generating grid", label: "Generate analysis grid" },
|
|
||||||
{ key: "Computing scores", label: "Compute accessibility scores" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps raw worker stage strings → UI stage keys.
|
||||||
|
* All three parallel worker stages (extract-pois sub-stages + build-valhalla)
|
||||||
|
* fold into "Processing OSM". Routing sub-jobs and BORIS NI ingest fold
|
||||||
|
* into "Computing scores" (they run during compute-scores Phase 1).
|
||||||
|
*/
|
||||||
|
function normalizeStage(raw: string): string {
|
||||||
|
if (
|
||||||
|
raw === "Clipping to bounding box" ||
|
||||||
|
raw === "Filtering OSM tags" ||
|
||||||
|
raw === "Importing to PostGIS" ||
|
||||||
|
raw === "Building routing graph"
|
||||||
|
) return "Processing OSM";
|
||||||
|
if (raw.startsWith("Routing ") || raw === "Ingesting BORIS NI") {
|
||||||
|
return "Computing scores";
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export type OverallStatus = "pending" | "active" | "completed" | "failed";
|
export type OverallStatus = "pending" | "active" | "completed" | "failed";
|
||||||
|
|
||||||
interface ProgressState {
|
interface ProgressState {
|
||||||
|
|
@ -57,9 +77,10 @@ function initialState(): ProgressState {
|
||||||
function reducer(state: ProgressState, action: Action): ProgressState {
|
function reducer(state: ProgressState, action: Action): ProgressState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "progress": {
|
case "progress": {
|
||||||
|
const stageKey = normalizeStage(action.stage);
|
||||||
let found = false;
|
let found = false;
|
||||||
const stages = state.stages.map((s) => {
|
const stages = state.stages.map((s) => {
|
||||||
if (s.key === action.stage) {
|
if (s.key === stageKey) {
|
||||||
found = true;
|
found = true;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
|
|
@ -94,9 +115,13 @@ function reducer(state: ProgressState, action: Action): ProgressState {
|
||||||
export function useJobProgress(jobId: string | null): ProgressState {
|
export function useJobProgress(jobId: string | null): ProgressState {
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
// Tracks whether the stream ended with a legitimate "completed" event so
|
||||||
|
// the subsequent connection-close (which fires onerror) is ignored.
|
||||||
|
const completedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
completedRef.current = false;
|
||||||
|
|
||||||
const es = new EventSource(`/api/admin/jobs/${jobId}/stream`);
|
const es = new EventSource(`/api/admin/jobs/${jobId}/stream`);
|
||||||
esRef.current = es;
|
esRef.current = es;
|
||||||
|
|
@ -104,10 +129,18 @@ export function useJobProgress(jobId: string | null): ProgressState {
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
const payload = JSON.parse(event.data) as SSEEvent;
|
const payload = JSON.parse(event.data) as SSEEvent;
|
||||||
if (payload.type === "heartbeat") return;
|
if (payload.type === "heartbeat") return;
|
||||||
|
if (payload.type === "completed") {
|
||||||
|
// Close before the server closes so the subsequent connection-close
|
||||||
|
// does not trigger onerror and overwrite the completed state.
|
||||||
|
completedRef.current = true;
|
||||||
|
es.close();
|
||||||
|
esRef.current = null;
|
||||||
|
}
|
||||||
dispatch(payload as Action);
|
dispatch(payload as Action);
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
|
if (completedRef.current) return; // Normal close after completion — ignore
|
||||||
dispatch({ type: "failed", error: "Lost connection to job stream" });
|
dispatch({ type: "failed", error: "Lost connection to job stream" });
|
||||||
es.close();
|
es.close();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,23 @@ CREATE INDEX IF NOT EXISTS idx_raw_pois_name
|
||||||
-- ─── Grid points ─────────────────────────────────────────────────────────────
|
-- ─── Grid points ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS grid_points (
|
CREATE TABLE IF NOT EXISTS grid_points (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
|
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
|
||||||
geom geometry(Point, 4326) NOT NULL,
|
geom geometry(Point, 4326) NOT NULL,
|
||||||
grid_x INTEGER NOT NULL,
|
grid_x INTEGER NOT NULL,
|
||||||
grid_y INTEGER NOT NULL,
|
grid_y INTEGER NOT NULL,
|
||||||
|
hidden_gem_score FLOAT4,
|
||||||
UNIQUE (city_slug, grid_x, grid_y)
|
UNIQUE (city_slug, grid_x, grid_y)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Migration for existing databases
|
||||||
|
ALTER TABLE grid_points ADD COLUMN IF NOT EXISTS hidden_gem_score FLOAT4;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug);
|
CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom);
|
CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grid_hidden_gem
|
||||||
|
ON grid_points (city_slug, hidden_gem_score)
|
||||||
|
WHERE hidden_gem_score IS NOT NULL;
|
||||||
|
|
||||||
-- ─── Pre-computed accessibility scores ───────────────────────────────────────
|
-- ─── Pre-computed accessibility scores ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -123,3 +130,31 @@ CREATE INDEX IF NOT EXISTS idx_isochrone_created
|
||||||
|
|
||||||
-- Auto-expire isochrone cache entries older than 30 days
|
-- Auto-expire isochrone cache entries older than 30 days
|
||||||
-- (handled by periodic cleanup or TTL logic in app)
|
-- (handled by periodic cleanup or TTL logic in app)
|
||||||
|
|
||||||
|
-- ─── Estate value zones ───────────────────────────────────────────────────────
|
||||||
|
-- Populated by datasource-specific worker jobs (currently: ingest-boris-ni).
|
||||||
|
-- Only present for cities whose bbox intersects a supported region.
|
||||||
|
|
||||||
|
-- Migration: rename if upgrading from the previous schema version
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'boris_zones')
|
||||||
|
AND NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'estate_value_zones')
|
||||||
|
THEN ALTER TABLE boris_zones RENAME TO estate_value_zones; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS estate_value_zones (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE,
|
||||||
|
geom geometry(GEOMETRY, 4326) NOT NULL,
|
||||||
|
value_eur_m2 NUMERIC,
|
||||||
|
zone_name TEXT,
|
||||||
|
usage_type TEXT,
|
||||||
|
usage_detail TEXT,
|
||||||
|
dev_state TEXT,
|
||||||
|
stichtag TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'boris-ni',
|
||||||
|
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_estate_value_zones_city ON estate_value_zones (city_slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_estate_value_zones_geom ON estate_value_zones USING GIST (geom);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export interface ComputeScoresJobData {
|
||||||
thresholds: number[];
|
thresholds: number[];
|
||||||
/** Set after compute-routing children are dispatched (internal two-phase state). */
|
/** Set after compute-routing children are dispatched (internal two-phase state). */
|
||||||
routingDispatched?: boolean;
|
routingDispatched?: boolean;
|
||||||
|
/** When true, ingest-boris-ni is dispatched in Phase 1 to run alongside routing jobs. */
|
||||||
|
ingestBorisNi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputeRoutingJobData {
|
export interface ComputeRoutingJobData {
|
||||||
|
|
@ -55,6 +57,11 @@ export interface RefreshCityJobData {
|
||||||
resolutionM?: number;
|
resolutionM?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IngestBorisNiJobData {
|
||||||
|
type: "ingest-boris-ni";
|
||||||
|
citySlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PipelineJobData =
|
export type PipelineJobData =
|
||||||
| DownloadPbfJobData
|
| DownloadPbfJobData
|
||||||
| ExtractPoisJobData
|
| ExtractPoisJobData
|
||||||
|
|
@ -62,7 +69,8 @@ export type PipelineJobData =
|
||||||
| ComputeScoresJobData
|
| ComputeScoresJobData
|
||||||
| ComputeRoutingJobData
|
| ComputeRoutingJobData
|
||||||
| BuildValhallaJobData
|
| BuildValhallaJobData
|
||||||
| RefreshCityJobData;
|
| RefreshCityJobData
|
||||||
|
| IngestBorisNiJobData;
|
||||||
|
|
||||||
// ─── Job options (BullMQ-compatible plain objects) ────────────────────────────
|
// ─── Job options (BullMQ-compatible plain objects) ────────────────────────────
|
||||||
|
|
||||||
|
|
@ -104,4 +112,10 @@ export const JOB_OPTIONS: Record<PipelineJobData["type"], object> = {
|
||||||
removeOnComplete: { age: 86400 * 7 },
|
removeOnComplete: { age: 86400 * 7 },
|
||||||
removeOnFail: { age: 86400 * 30 },
|
removeOnFail: { age: 86400 * 30 },
|
||||||
},
|
},
|
||||||
|
"ingest-boris-ni": {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: { type: "fixed", delay: 5000 },
|
||||||
|
removeOnComplete: { age: 86400 * 7 },
|
||||||
|
removeOnFail: { age: 86400 * 30 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { handleGenerateGrid } from "./jobs/generate-grid.js";
|
||||||
import { handleComputeScores } from "./jobs/compute-scores.js";
|
import { handleComputeScores } from "./jobs/compute-scores.js";
|
||||||
import { handleComputeRouting } from "./jobs/compute-routing.js";
|
import { handleComputeRouting } from "./jobs/compute-routing.js";
|
||||||
import { handleRefreshCity } from "./jobs/refresh-city.js";
|
import { handleRefreshCity } from "./jobs/refresh-city.js";
|
||||||
|
import { handleIngestBorisNi } from "./jobs/ingest-boris-ni.js";
|
||||||
|
|
||||||
console.log("[worker] Starting Transportationer pipeline worker…");
|
console.log("[worker] Starting Transportationer pipeline worker…");
|
||||||
|
|
||||||
|
|
@ -28,6 +29,8 @@ const worker = new Worker<PipelineJobData>(
|
||||||
return handleComputeRouting(job as Job<any>);
|
return handleComputeRouting(job as Job<any>);
|
||||||
case "refresh-city":
|
case "refresh-city":
|
||||||
return handleRefreshCity(job as Job<any>);
|
return handleRefreshCity(job as Job<any>);
|
||||||
|
case "ingest-boris-ni":
|
||||||
|
return handleIngestBorisNi(job as Job<any>);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown job type: ${(job.data as any).type}`);
|
throw new Error(`Unknown job type: ${(job.data as any).type}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export type ComputeScoresData = {
|
||||||
thresholds: number[];
|
thresholds: number[];
|
||||||
/** Persisted after routing children are dispatched to distinguish phase 1 from phase 2. */
|
/** Persisted after routing children are dispatched to distinguish phase 1 from phase 2. */
|
||||||
routingDispatched?: boolean;
|
routingDispatched?: boolean;
|
||||||
|
/** When true, ingest-boris-ni is dispatched in Phase 1 to run alongside routing jobs. */
|
||||||
|
ingestBorisNi?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INSERT_CHUNK = 2000;
|
const INSERT_CHUNK = 2000;
|
||||||
|
|
@ -94,6 +96,8 @@ export async function handleComputeScores(
|
||||||
|
|
||||||
// Enqueue one routing child per (mode, category). Each child registers
|
// Enqueue one routing child per (mode, category). Each child registers
|
||||||
// itself to this parent job via opts.parent, so BullMQ tracks completion.
|
// itself to this parent job via opts.parent, so BullMQ tracks completion.
|
||||||
|
// For NI cities, ingest-boris-ni is also enqueued here so it runs in
|
||||||
|
// parallel with the routing jobs rather than sequentially after them.
|
||||||
const queue = new Queue("pipeline", { connection: createBullMQConnection() });
|
const queue = new Queue("pipeline", { connection: createBullMQConnection() });
|
||||||
try {
|
try {
|
||||||
for (const mode of modes) {
|
for (const mode of modes) {
|
||||||
|
|
@ -116,6 +120,21 @@ export async function handleComputeScores(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch BORIS NI ingest as a sibling child so it runs during routing.
|
||||||
|
if (job.data.ingestBorisNi) {
|
||||||
|
await queue.add(
|
||||||
|
"ingest-boris-ni",
|
||||||
|
{ type: "ingest-boris-ni", citySlug },
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
backoff: { type: "fixed", delay: 5000 },
|
||||||
|
removeOnComplete: { age: 86400 * 7 },
|
||||||
|
removeOnFail: { age: 86400 * 30 },
|
||||||
|
parent: { id: job.id!, queue: queue.qualifiedName },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await queue.close();
|
await queue.close();
|
||||||
}
|
}
|
||||||
|
|
@ -202,15 +221,13 @@ export async function handleComputeScores(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute and insert scores for every threshold × profile combination.
|
// Compute and insert scores for every threshold × profile combination.
|
||||||
for (let ti = 0; ti < thresholds.length; ti++) {
|
// Each threshold writes to distinct rows (threshold_min is part of the PK),
|
||||||
const thresholdMin = thresholds[ti];
|
// so all thresholds can be processed concurrently without conflicts.
|
||||||
const threshold_s = thresholdMin * 60;
|
// Node.js is single-threaded so completedThresholds++ is safe.
|
||||||
|
let completedThresholds = 0;
|
||||||
|
|
||||||
await job.updateProgress({
|
await Promise.all(thresholds.map(async (thresholdMin) => {
|
||||||
stage: "Computing scores",
|
const threshold_s = thresholdMin * 60;
|
||||||
pct: 70 + Math.round(((ti + 1) / thresholds.length) * 28),
|
|
||||||
message: `${thresholdMin}min — inserting scores for all profiles…`,
|
|
||||||
} satisfies JobProgress);
|
|
||||||
|
|
||||||
const gpIdArr: string[] = [];
|
const gpIdArr: string[] = [];
|
||||||
const catArr: string[] = [];
|
const catArr: string[] = [];
|
||||||
|
|
@ -234,6 +251,9 @@ export async function handleComputeScores(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunks within a threshold stay sequential — with all thresholds running
|
||||||
|
// concurrently we already have up to thresholds.length parallel INSERT
|
||||||
|
// streams, which saturates the connection pool without overwhelming it.
|
||||||
for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) {
|
for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) {
|
||||||
const end = Math.min(i + INSERT_CHUNK, gpIdArr.length);
|
const end = Math.min(i + INSERT_CHUNK, gpIdArr.length);
|
||||||
await Promise.resolve(sql`
|
await Promise.resolve(sql`
|
||||||
|
|
@ -270,13 +290,70 @@ export async function handleComputeScores(
|
||||||
computed_at = now()
|
computed_at = now()
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
completedThresholds++;
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Computing scores",
|
||||||
|
pct: 70 + Math.round((completedThresholds / thresholds.length) * 28),
|
||||||
|
message: `${completedThresholds} / ${thresholds.length} thresholds done…`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
}));
|
||||||
|
|
||||||
await Promise.resolve(sql`
|
await Promise.resolve(sql`
|
||||||
UPDATE cities SET status = 'ready', last_ingested = now()
|
UPDATE cities SET status = 'ready', last_ingested = now()
|
||||||
WHERE slug = ${citySlug}
|
WHERE slug = ${citySlug}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Compute hidden gem scores per grid point for cities that have estate value zones.
|
||||||
|
// Each grid point looks up the nearest zone's price, ranks it within its accessibility
|
||||||
|
// decile, and stores hidden_gem_score = composite_accessibility × (1 − price_rank).
|
||||||
|
const gemThreshold = thresholds.includes(15) ? 15 : thresholds[0];
|
||||||
|
const [{ n }] = await Promise.resolve(sql<{ n: number }[]>`
|
||||||
|
SELECT count(*)::int AS n FROM estate_value_zones
|
||||||
|
WHERE city_slug = ${citySlug} AND value_eur_m2 IS NOT NULL
|
||||||
|
`);
|
||||||
|
if (n > 0) {
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Computing scores",
|
||||||
|
pct: 99,
|
||||||
|
message: "Computing hidden gem scores…",
|
||||||
|
} satisfies JobProgress);
|
||||||
|
await Promise.resolve(sql`
|
||||||
|
WITH grid_with_price AS (
|
||||||
|
-- For each grid point, get composite accessibility score and nearest zone price
|
||||||
|
SELECT
|
||||||
|
gp.id,
|
||||||
|
COALESCE(AVG(gs.score), 0) AS composite_score,
|
||||||
|
ROUND(COALESCE(AVG(gs.score), 0) * 10)::int AS score_decile,
|
||||||
|
(
|
||||||
|
SELECT ez.value_eur_m2
|
||||||
|
FROM estate_value_zones ez
|
||||||
|
WHERE ez.city_slug = ${citySlug} AND ez.value_eur_m2 IS NOT NULL
|
||||||
|
ORDER BY gp.geom <-> ez.geom
|
||||||
|
LIMIT 1
|
||||||
|
) AS value_eur_m2
|
||||||
|
FROM grid_points gp
|
||||||
|
JOIN grid_scores gs ON gs.grid_point_id = gp.id
|
||||||
|
WHERE gp.city_slug = ${citySlug}
|
||||||
|
AND gs.travel_mode = 'walking'
|
||||||
|
AND gs.threshold_min = ${gemThreshold}
|
||||||
|
AND gs.profile = 'universal'
|
||||||
|
GROUP BY gp.id
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
composite_score,
|
||||||
|
PERCENT_RANK() OVER (PARTITION BY score_decile ORDER BY value_eur_m2) AS price_rank
|
||||||
|
FROM grid_with_price
|
||||||
|
WHERE value_eur_m2 IS NOT NULL
|
||||||
|
)
|
||||||
|
UPDATE grid_points gp
|
||||||
|
SET hidden_gem_score = (ranked.composite_score * (1.0 - ranked.price_rank))::float4
|
||||||
|
FROM ranked WHERE gp.id = ranked.id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Computing scores",
|
stage: "Computing scores",
|
||||||
pct: 100,
|
pct: 100,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { createWriteStream, mkdirSync } from "fs";
|
import { createWriteStream, mkdirSync, statSync, renameSync } from "fs";
|
||||||
import { pipeline } from "stream/promises";
|
|
||||||
import { Writable } from "stream";
|
import { Writable } from "stream";
|
||||||
import type { JobProgress } from "@transportationer/shared";
|
import type { JobProgress } from "@transportationer/shared";
|
||||||
|
|
||||||
|
|
@ -27,6 +26,23 @@ export async function handleDownloadPbf(
|
||||||
|
|
||||||
mkdirSync(OSM_DATA_DIR, { recursive: true });
|
mkdirSync(OSM_DATA_DIR, { recursive: true });
|
||||||
const outputPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
|
const outputPath = `${OSM_DATA_DIR}/${citySlug}-latest.osm.pbf`;
|
||||||
|
const tmpPath = `${outputPath}.tmp`;
|
||||||
|
|
||||||
|
// Idempotency: skip if a complete file is already on disk (supports
|
||||||
|
// parallel download-pbf instances for the same city PBF).
|
||||||
|
try {
|
||||||
|
const stat = statSync(outputPath);
|
||||||
|
if (stat.size > 0) {
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Downloading PBF",
|
||||||
|
pct: 100,
|
||||||
|
message: `Already on disk: ${outputPath} (${(stat.size / 1_048_576).toFixed(1)} MB)`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist — fall through to download.
|
||||||
|
}
|
||||||
|
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Downloading PBF",
|
stage: "Downloading PBF",
|
||||||
|
|
@ -49,7 +65,9 @@ export async function handleDownloadPbf(
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
let lastPct = -1;
|
let lastPct = -1;
|
||||||
|
|
||||||
const fileStream = createWriteStream(outputPath);
|
// Write to a temp path; rename to final path on completion so concurrent
|
||||||
|
// instances see a complete file or nothing (never a partial file).
|
||||||
|
const fileStream = createWriteStream(tmpPath);
|
||||||
|
|
||||||
// Count bytes through a transform, then write to file
|
// Count bytes through a transform, then write to file
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
@ -94,6 +112,9 @@ export async function handleDownloadPbf(
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Atomically promote the temp file to the final path.
|
||||||
|
renameSync(tmpPath, outputPath);
|
||||||
|
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Downloading PBF",
|
stage: "Downloading PBF",
|
||||||
pct: 100,
|
pct: 100,
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ export async function handleExtractPois(
|
||||||
INSERT INTO raw_pois (osm_id, osm_type, city_slug, category, subcategory, name, tags, geom)
|
INSERT INTO raw_pois (osm_id, osm_type, city_slug, category, subcategory, name, tags, geom)
|
||||||
SELECT osm_id, osm_type, city_slug, category, subcategory, name, tags, geom
|
SELECT osm_id, osm_type, city_slug, category, subcategory, name, tags, geom
|
||||||
FROM ${sql(stagingTable)}
|
FROM ${sql(stagingTable)}
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
`);
|
`);
|
||||||
await Promise.resolve(sql`DROP TABLE IF EXISTS ${sql(stagingTable)}`);
|
await Promise.resolve(sql`DROP TABLE IF EXISTS ${sql(stagingTable)}`);
|
||||||
|
|
||||||
|
|
|
||||||
262
worker/src/jobs/ingest-boris-ni.ts
Normal file
262
worker/src/jobs/ingest-boris-ni.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
/**
|
||||||
|
* Ingest BORIS NI (Niedersachsen Bodenrichtwerte) estate value zones.
|
||||||
|
*
|
||||||
|
* Fetches from the LGLN WFS and stores into estate_value_zones.
|
||||||
|
* Only enqueued by refresh-city when the city's bbox intersects Niedersachsen.
|
||||||
|
*/
|
||||||
|
import type { Job } from "bullmq";
|
||||||
|
import { getSql } from "../db.js";
|
||||||
|
import type { JobProgress } from "@transportationer/shared";
|
||||||
|
|
||||||
|
export type IngestBorisNiData = {
|
||||||
|
type: "ingest-boris-ni";
|
||||||
|
citySlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WFS_BASE = "https://opendata.lgln.niedersachsen.de/doorman/noauth/boris_wfs";
|
||||||
|
const CHUNK = 500;
|
||||||
|
|
||||||
|
// ─── GML Parser (WFS 2.0 / GML 3.2) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFirst(xml: string, localName: string): string | null {
|
||||||
|
const re = new RegExp(`<(?:[\\w]+:)?${localName}(?:\\s[^>]*)?>([^<]*)`, "");
|
||||||
|
const m = re.exec(xml);
|
||||||
|
return m ? m[1].trim() || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePosListCoords(posList: string): [number, number][] {
|
||||||
|
const nums = posList.trim().split(/\s+/).map(Number);
|
||||||
|
const coords: [number, number][] = [];
|
||||||
|
for (let i = 0; i + 1 < nums.length; i += 2) {
|
||||||
|
coords.push([nums[i + 1], nums[i]]); // lat,lon → lon,lat (GeoJSON)
|
||||||
|
}
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGmlPolygon(xml: string): { type: "Polygon"; coordinates: [number, number][][] } | null {
|
||||||
|
const extMatch = /<gml:exterior[\s\S]*?<gml:posList[^>]*>([\s\S]*?)<\/gml:posList>/.exec(xml);
|
||||||
|
if (!extMatch) return null;
|
||||||
|
const exterior = parsePosListCoords(extMatch[1]);
|
||||||
|
if (exterior.length < 3) return null;
|
||||||
|
|
||||||
|
const rings: [number, number][][] = [exterior];
|
||||||
|
const intRe = /<gml:interior[\s\S]*?<gml:posList[^>]*>([\s\S]*?)<\/gml:posList>/g;
|
||||||
|
let intMatch: RegExpExecArray | null;
|
||||||
|
while ((intMatch = intRe.exec(xml)) !== null) {
|
||||||
|
const interior = parsePosListCoords(intMatch[1]);
|
||||||
|
if (interior.length >= 3) rings.push(interior);
|
||||||
|
}
|
||||||
|
return { type: "Polygon", coordinates: rings };
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeoJsonGeometry =
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
type EstateValueFeature = {
|
||||||
|
geometry: GeoJsonGeometry;
|
||||||
|
value: number | null;
|
||||||
|
zoneName: string | null;
|
||||||
|
usageType: string | null;
|
||||||
|
usageDetail: string | null;
|
||||||
|
devState: string | null;
|
||||||
|
stichtag: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseMember(xml: string): EstateValueFeature | null {
|
||||||
|
const value = extractFirst(xml, "bodenrichtwert");
|
||||||
|
const zoneName = extractFirst(xml, "bodenrichtwertzoneName");
|
||||||
|
const art = extractFirst(xml, "art");
|
||||||
|
const ergaenzung = extractFirst(xml, "ergaenzung");
|
||||||
|
const devState = extractFirst(xml, "entwicklungszustand");
|
||||||
|
const stichtag = extractFirst(xml, "stichtag");
|
||||||
|
|
||||||
|
// Only keep Bauland (B) zones designated for residential or mixed use.
|
||||||
|
// entwicklungszustand=LF covers Landwirtschaft, Forst (F), Grünland (GR),
|
||||||
|
// and Ackerland (A) — all priced €0.65–€4/m², irrelevant for housing.
|
||||||
|
// Within Bauland, art W* = Wohnbaufläche (residential) and M* = Mischgebiet
|
||||||
|
// (mixed use, housing permitted); GE/GI (commercial/industrial) and SO
|
||||||
|
// (special use) are excluded as houses cannot be built there.
|
||||||
|
if (devState !== "B") return null;
|
||||||
|
if (!art || (!art.startsWith("W") && !art.startsWith("M"))) return null;
|
||||||
|
|
||||||
|
let geometry: GeoJsonGeometry | null = null;
|
||||||
|
|
||||||
|
const polygonMatch = /<gml:Polygon[^>]*>([\s\S]*?)<\/gml:Polygon>/.exec(xml);
|
||||||
|
if (polygonMatch) {
|
||||||
|
geometry = parseGmlPolygon(polygonMatch[0]);
|
||||||
|
} else {
|
||||||
|
const multiMatch = /<gml:MultiSurface[^>]*>([\s\S]*?)<\/gml:MultiSurface>/.exec(xml);
|
||||||
|
if (multiMatch) {
|
||||||
|
const polys: [number, number][][][] = [];
|
||||||
|
const surfRe = /<gml:surfaceMember[^>]*>([\s\S]*?)<\/gml:surfaceMember>/g;
|
||||||
|
let surfMatch: RegExpExecArray | null;
|
||||||
|
while ((surfMatch = surfRe.exec(multiMatch[0])) !== null) {
|
||||||
|
const polyFrag = /<gml:Polygon[^>]*>([\s\S]*?)<\/gml:Polygon>/.exec(surfMatch[0]);
|
||||||
|
if (polyFrag) {
|
||||||
|
const poly = parseGmlPolygon(polyFrag[0]);
|
||||||
|
if (poly) polys.push(poly.coordinates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (polys.length > 0) {
|
||||||
|
geometry = { type: "MultiPolygon", coordinates: polys };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geometry) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
geometry,
|
||||||
|
value: value !== null ? parseFloat(value) : null,
|
||||||
|
zoneName,
|
||||||
|
usageType: art,
|
||||||
|
usageDetail: ergaenzung,
|
||||||
|
devState,
|
||||||
|
stichtag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGmlFeatures(gml: string): EstateValueFeature[] {
|
||||||
|
const features: EstateValueFeature[] = [];
|
||||||
|
const memberRe = /<wfs:member>([\s\S]*?)<\/wfs:member>/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = memberRe.exec(gml)) !== null) {
|
||||||
|
const f = parseMember(match[1]);
|
||||||
|
if (f) features.push(f);
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Job handler ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleIngestBorisNi(job: Job<IngestBorisNiData>): Promise<void> {
|
||||||
|
const { citySlug } = job.data;
|
||||||
|
const sql = getSql();
|
||||||
|
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 0,
|
||||||
|
message: `Reading bbox for ${citySlug}`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
|
||||||
|
const bboxRows = await Promise.resolve(sql<{
|
||||||
|
minlng: number; minlat: number; maxlng: number; maxlat: number;
|
||||||
|
}[]>`
|
||||||
|
SELECT
|
||||||
|
ST_XMin(bbox)::float AS minlng,
|
||||||
|
ST_YMin(bbox)::float AS minlat,
|
||||||
|
ST_XMax(bbox)::float AS maxlng,
|
||||||
|
ST_YMax(bbox)::float AS maxlat
|
||||||
|
FROM cities WHERE slug = ${citySlug} AND bbox IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (bboxRows.length === 0) {
|
||||||
|
throw new Error(`No bbox set for city ${citySlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { minlng, minlat, maxlng, maxlat } = bboxRows[0];
|
||||||
|
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 10,
|
||||||
|
message: `Fetching BORIS NI WFS for ${citySlug}`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
|
||||||
|
// Fetch from BORIS NI WFS (lat/lon axis order for EPSG:4326)
|
||||||
|
const wfsUrl = new URL(WFS_BASE);
|
||||||
|
wfsUrl.searchParams.set("SERVICE", "WFS");
|
||||||
|
wfsUrl.searchParams.set("VERSION", "2.0.0");
|
||||||
|
wfsUrl.searchParams.set("REQUEST", "GetFeature");
|
||||||
|
wfsUrl.searchParams.set("TYPENAMES", "boris:BR_BodenrichtwertZonal");
|
||||||
|
wfsUrl.searchParams.set("SRSNAME", "urn:ogc:def:crs:EPSG::4326");
|
||||||
|
wfsUrl.searchParams.set("BBOX", `${minlat},${minlng},${maxlat},${maxlng},urn:ogc:def:crs:EPSG::4326`);
|
||||||
|
wfsUrl.searchParams.set("COUNT", "10000");
|
||||||
|
|
||||||
|
const response = await fetch(wfsUrl.toString(), {
|
||||||
|
headers: { Accept: "application/gml+xml; version=3.2" },
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`BORIS NI WFS returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gml = await response.text();
|
||||||
|
|
||||||
|
if (gml.includes("<ows:ExceptionReport")) {
|
||||||
|
console.warn(`[ingest-boris-ni] WFS returned exception report for ${citySlug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 50,
|
||||||
|
message: "Parsing GML response",
|
||||||
|
} satisfies JobProgress);
|
||||||
|
|
||||||
|
const features = parseGmlFeatures(gml);
|
||||||
|
console.log(`[ingest-boris-ni] Parsed ${features.length} zones for ${citySlug}`);
|
||||||
|
|
||||||
|
// Replace all existing BORIS NI data for this city
|
||||||
|
await Promise.resolve(sql`
|
||||||
|
DELETE FROM estate_value_zones WHERE city_slug = ${citySlug} AND source = 'boris-ni'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 100,
|
||||||
|
message: `No zones found for ${citySlug}`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (let i = 0; i < features.length; i += CHUNK) {
|
||||||
|
const chunk = features.slice(i, i + CHUNK);
|
||||||
|
|
||||||
|
const geomJsons = chunk.map((f) => JSON.stringify(f.geometry));
|
||||||
|
const values = chunk.map((f) => f.value);
|
||||||
|
const zoneNames = chunk.map((f) => f.zoneName);
|
||||||
|
const usageTypes = chunk.map((f) => f.usageType);
|
||||||
|
const usageDetails = chunk.map((f) => f.usageDetail);
|
||||||
|
const devStates = chunk.map((f) => f.devState);
|
||||||
|
const stichtags = chunk.map((f) => f.stichtag);
|
||||||
|
|
||||||
|
await Promise.resolve(sql`
|
||||||
|
INSERT INTO estate_value_zones
|
||||||
|
(city_slug, geom, value_eur_m2, zone_name, usage_type, usage_detail, dev_state, stichtag, source)
|
||||||
|
SELECT
|
||||||
|
${citySlug},
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON(g), 4326),
|
||||||
|
v,
|
||||||
|
zn, ut, ud, ds, st,
|
||||||
|
'boris-ni'
|
||||||
|
FROM unnest(
|
||||||
|
${geomJsons}::text[],
|
||||||
|
${values}::numeric[],
|
||||||
|
${zoneNames}::text[],
|
||||||
|
${usageTypes}::text[],
|
||||||
|
${usageDetails}::text[],
|
||||||
|
${devStates}::text[],
|
||||||
|
${stichtags}::text[]
|
||||||
|
) AS t(g, v, zn, ut, ud, ds, st)
|
||||||
|
`);
|
||||||
|
|
||||||
|
inserted += chunk.length;
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 50 + Math.round((inserted / features.length) * 50),
|
||||||
|
message: `Stored ${inserted}/${features.length} zones`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ingest-boris-ni] ✓ Stored ${inserted} BORIS NI zones for ${citySlug}`);
|
||||||
|
|
||||||
|
await job.updateProgress({
|
||||||
|
stage: "Ingesting BORIS NI",
|
||||||
|
pct: 100,
|
||||||
|
message: `BORIS NI ingest complete: ${inserted} zones`,
|
||||||
|
} satisfies JobProgress);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,11 @@ export type RefreshCityData = {
|
||||||
|
|
||||||
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
|
const OSM_DATA_DIR = process.env.OSM_DATA_DIR ?? "/data/osm";
|
||||||
|
|
||||||
|
/** True when the given bbox intersects Niedersachsen. */
|
||||||
|
function isInNiedersachsen(minLng: number, minLat: number, maxLng: number, maxLat: number): boolean {
|
||||||
|
return minLng < 11.779 && maxLng > 6.526 && minLat < 54.033 && maxLat > 51.197;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleRefreshCity(
|
export async function handleRefreshCity(
|
||||||
job: Job<RefreshCityData>,
|
job: Job<RefreshCityData>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -50,72 +55,81 @@ export async function handleRefreshCity(
|
||||||
UPDATE cities SET status = 'processing' WHERE slug = ${citySlug}
|
UPDATE cities SET status = 'processing' WHERE slug = ${citySlug}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// FlowProducer creates a dependency chain evaluated bottom-up:
|
// Shared download node factory — produces an idempotent download-pbf node.
|
||||||
// download → extract → generate-grid → build-valhalla → compute-scores
|
// Two independent nodes with the same geofabrikUrl are safe: the idempotency
|
||||||
const flow = new FlowProducer({ connection: createBullMQConnection() });
|
// check in download-pbf.ts skips the download if the file already exists.
|
||||||
|
const downloadNode = () => ({
|
||||||
|
name: "download-pbf",
|
||||||
|
queueName: "pipeline",
|
||||||
|
data: { type: "download-pbf" as const, citySlug, geofabrikUrl },
|
||||||
|
opts: JOB_OPTIONS["download-pbf"],
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
// For cities in Niedersachsen, ingest-boris-ni is dispatched in Phase 1
|
||||||
await flow.add({
|
// of compute-scores so it runs in parallel with the routing jobs.
|
||||||
name: "compute-scores",
|
const niApplicable = !!(bbox && isInNiedersachsen(...bbox));
|
||||||
queueName: "pipeline",
|
|
||||||
data: {
|
// Parallel pipeline DAG (bottom-up — leaves execute first):
|
||||||
type: "compute-scores",
|
//
|
||||||
citySlug,
|
// download-pbf ─┬─→ extract-pois ──┐
|
||||||
modes: ["walking", "cycling", "driving"],
|
// │ ├─→ generate-grid → compute-scores
|
||||||
thresholds: [5, 10, 15, 20, 30],
|
// download-pbf ─└─→ build-valhalla ─┘
|
||||||
},
|
//
|
||||||
opts: JOB_OPTIONS["compute-scores"],
|
// compute-scores Phase 1 also dispatches ingest-boris-ni (NI cities only)
|
||||||
children: [
|
// as a child alongside the routing jobs, so it runs during routing.
|
||||||
{
|
const rootNode = {
|
||||||
name: "generate-grid",
|
name: "compute-scores",
|
||||||
queueName: "pipeline",
|
queueName: "pipeline",
|
||||||
data: {
|
data: {
|
||||||
type: "generate-grid",
|
type: "compute-scores" as const,
|
||||||
citySlug,
|
citySlug,
|
||||||
resolutionM,
|
modes: ["walking", "cycling", "driving"] as const,
|
||||||
},
|
thresholds: [5, 10, 15, 20, 30],
|
||||||
opts: JOB_OPTIONS["generate-grid"],
|
ingestBorisNi: niApplicable,
|
||||||
children: [
|
},
|
||||||
{
|
opts: JOB_OPTIONS["compute-scores"],
|
||||||
name: "build-valhalla",
|
children: [
|
||||||
queueName: "valhalla", // handled by the dedicated valhalla-worker
|
{
|
||||||
data: {
|
name: "generate-grid",
|
||||||
type: "build-valhalla",
|
queueName: "pipeline",
|
||||||
citySlug,
|
data: { type: "generate-grid" as const, citySlug, resolutionM },
|
||||||
pbfPath,
|
opts: JOB_OPTIONS["generate-grid"],
|
||||||
...(bbox ? { bbox } : {}),
|
children: [
|
||||||
},
|
// extract-pois and build-valhalla run in parallel — each gets its
|
||||||
opts: JOB_OPTIONS["build-valhalla"],
|
// own download-pbf child; the idempotency guard ensures only one
|
||||||
children: [
|
// actually downloads when they race.
|
||||||
{
|
{
|
||||||
name: "extract-pois",
|
name: "extract-pois",
|
||||||
queueName: "pipeline",
|
queueName: "pipeline",
|
||||||
data: {
|
data: {
|
||||||
type: "extract-pois",
|
type: "extract-pois" as const,
|
||||||
citySlug,
|
citySlug,
|
||||||
pbfPath,
|
pbfPath,
|
||||||
...(bbox ? { bbox } : {}),
|
...(bbox ? { bbox } : {}),
|
||||||
},
|
|
||||||
opts: JOB_OPTIONS["extract-pois"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: "download-pbf",
|
|
||||||
queueName: "pipeline",
|
|
||||||
data: {
|
|
||||||
type: "download-pbf",
|
|
||||||
citySlug,
|
|
||||||
geofabrikUrl,
|
|
||||||
},
|
|
||||||
opts: JOB_OPTIONS["download-pbf"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
opts: JOB_OPTIONS["extract-pois"],
|
||||||
},
|
children: [downloadNode()],
|
||||||
],
|
},
|
||||||
});
|
{
|
||||||
|
name: "build-valhalla",
|
||||||
|
queueName: "valhalla",
|
||||||
|
data: {
|
||||||
|
type: "build-valhalla" as const,
|
||||||
|
citySlug,
|
||||||
|
pbfPath,
|
||||||
|
...(bbox ? { bbox } : {}),
|
||||||
|
},
|
||||||
|
opts: JOB_OPTIONS["build-valhalla"],
|
||||||
|
children: [downloadNode()],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const flow = new FlowProducer({ connection: createBullMQConnection() });
|
||||||
|
try {
|
||||||
|
await flow.add(rootNode);
|
||||||
} finally {
|
} finally {
|
||||||
await flow.close();
|
await flow.close();
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +137,6 @@ export async function handleRefreshCity(
|
||||||
await job.updateProgress({
|
await job.updateProgress({
|
||||||
stage: "Orchestrating pipeline",
|
stage: "Orchestrating pipeline",
|
||||||
pct: 100,
|
pct: 100,
|
||||||
message: "All pipeline jobs enqueued. Processing will begin shortly.",
|
message: `All pipeline jobs enqueued${niApplicable ? " (includes BORIS NI, parallel with routing)" : ""}. Processing will begin shortly.`,
|
||||||
} satisfies JobProgress);
|
} satisfies JobProgress);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue