From e94d6606867eda3ce98ae74ef1551e45d00a16d1 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Thu, 5 Mar 2026 00:38:21 +0100 Subject: [PATCH] feat: add selection by city boundary, show that, stats only for city, not bbox --- apps/web/app/admin/cities/new/page.tsx | 411 +++++++++--------- apps/web/app/api/admin/cities/route.ts | 37 +- .../app/api/admin/jobs/[id]/stream/route.ts | 129 ++++-- apps/web/app/api/cities/route.ts | 5 +- apps/web/app/api/stats/route.ts | 8 +- apps/web/app/page.tsx | 2 + apps/web/components/map-view.tsx | 37 ++ apps/web/hooks/use-job-progress.ts | 121 +++--- infra/schema.sql | 2 + shared/src/types.ts | 5 +- worker/src/index.ts | 19 +- worker/src/jobs/compute-scores.ts | 8 +- 12 files changed, 482 insertions(+), 302 deletions(-) diff --git a/apps/web/app/admin/cities/new/page.tsx b/apps/web/app/admin/cities/new/page.tsx index 10e7a7f..709efa5 100644 --- a/apps/web/app/admin/cities/new/page.tsx +++ b/apps/web/app/admin/cities/new/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared"; import { useJobProgress } from "@/hooks/use-job-progress"; +import type { StageStatus, RoutingDetail as RoutingDetailType } from "@/hooks/use-job-progress"; type Step = "browse" | "confirm" | "ingest"; @@ -186,7 +187,7 @@ function GeofabrikBrowser({ ); } -// ─── Nominatim geocoder + radius selector + mini map ───────────────────────── +// ─── Nominatim polygon geocoder + mini map ─────────────────────────────────── interface NominatimResult { place_id: number; @@ -195,71 +196,34 @@ interface NominatimResult { lon: string; type: string; class: string; -} - -const RADIUS_OPTIONS = [2, 5, 10, 15, 20, 30]; - -function computeBbox( - lat: number, - lng: number, - radiusKm: number, -): [number, number, number, number] { - const latDelta = radiusKm / 111.0; - const lngDelta = radiusKm / (111.0 * Math.cos((lat * Math.PI) / 180)); - return [lng - lngDelta, lat - latDelta, lng + lngDelta, lat + latDelta]; -} - -function bboxToGeoJSON(bbox: [number, number, number, number]) { - const [w, s, e, n] = bbox; - return { - type: "Feature" as const, - geometry: { - type: "Polygon" as const, - coordinates: [ - [ - [w, s], - [e, s], - [e, n], - [w, n], - [w, s], - ], - ], - }, - properties: {}, - }; + geojson?: { type: string; coordinates: unknown }; } function LocationSelector({ regionGeometry, - onBboxChange, + onBoundaryChange, }: { regionGeometry: GeofabrikFeature["geometry"]; - onBboxChange: (bbox: [number, number, number, number] | null) => void; + onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void; }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [selected, setSelected] = useState(null); - const [radius, setRadius] = useState(10); const [showDropdown, setShowDropdown] = useState(false); const [searching, setSearching] = useState(false); const mapContainerRef = useRef(null); const mapRef = useRef(null); const mapReadyRef = useRef(false); - const onBboxChangeRef = useRef(onBboxChange); - onBboxChangeRef.current = onBboxChange; + const onBoundaryChangeRef = useRef(onBoundaryChange); + onBoundaryChangeRef.current = onBoundaryChange; - const bbox = useMemo((): [number, number, number, number] | null => { - if (!selected) return null; - return computeBbox(parseFloat(selected.lat), parseFloat(selected.lon), radius); - }, [selected, radius]); - - // Notify parent when bbox changes + // Notify parent when selection changes useEffect(() => { - onBboxChangeRef.current(bbox); - }, [bbox]); + onBoundaryChangeRef.current(selected?.geojson ?? null); + }, [selected]); - // Debounced Nominatim search + // Debounced Nominatim search — request polygon_geojson + featuretype=settlement useEffect(() => { if (query.length < 2) { setResults([]); @@ -269,11 +233,15 @@ function LocationSelector({ setSearching(true); try { const res = await fetch( - `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&addressdetails=0`, + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&polygon_geojson=1&featuretype=settlement&format=json&limit=8`, { headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } }, ); const data: NominatimResult[] = await res.json(); - setResults(data); + // Keep only results that have a real polygon boundary + const polygons = data.filter( + (r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon", + ); + setResults(polygons); setShowDropdown(true); } catch { setResults([]); @@ -284,15 +252,27 @@ function LocationSelector({ return () => clearTimeout(timer); }, [query]); - // Apply bbox to mini map - const applyBboxToMap = useCallback( - (map: import("maplibre-gl").Map, b: [number, number, number, number] | null) => { + // Apply boundary polygon to mini map + const applyBoundaryToMap = useCallback( + (map: import("maplibre-gl").Map, geojson: NominatimResult["geojson"] | null) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const src = map.getSource("area-bbox") as any; + const src = map.getSource("area-boundary") as any; if (!src) return; - if (b) { - src.setData(bboxToGeoJSON(b)); - map.fitBounds([b[0], b[1], b[2], b[3]], { padding: 40, duration: 500 }); + if (geojson) { + src.setData({ type: "Feature", geometry: geojson, properties: {} }); + // Fit to boundary bbox + try { + const coords: number[][] = + geojson.type === "Polygon" + ? (geojson.coordinates as number[][][])[0] + : (geojson.coordinates as number[][][][])[0][0]; + const lngs = coords.map((c) => c[0]); + const lats = coords.map((c) => c[1]); + map.fitBounds( + [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)], + { padding: 40, duration: 500 }, + ); + } catch { /* ignore */ } } else { src.setData({ type: "FeatureCollection", features: [] }); } @@ -300,11 +280,11 @@ function LocationSelector({ [], ); - // Update mini map when bbox changes + // Update mini map when selection changes useEffect(() => { const map = mapRef.current; - if (map && mapReadyRef.current) applyBboxToMap(map, bbox); - }, [bbox, applyBboxToMap]); + if (map && mapReadyRef.current) applyBoundaryToMap(map, selected?.geojson ?? null); + }, [selected, applyBoundaryToMap]); // Initialize mini map useEffect(() => { @@ -357,49 +337,39 @@ function LocationSelector({ paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] }, }); - // Selected sub-area bbox - map.addSource("area-bbox", { + // Selected city boundary polygon + map.addSource("area-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addLayer({ - id: "area-bbox-fill", + id: "area-boundary-fill", type: "fill", - source: "area-bbox", + source: "area-boundary", paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 }, }); map.addLayer({ - id: "area-bbox-line", + id: "area-boundary-line", type: "line", - source: "area-bbox", + source: "area-boundary", paint: { "line-color": "#2563eb", "line-width": 2 }, }); // Fit to region if available if (regionGeometry) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const src = map.getSource("area-region") as any; - if (src) { - // Fit map to the region outline - try { - const coords: number[][] = - regionGeometry.type === "Polygon" - ? regionGeometry.coordinates[0] - : regionGeometry.coordinates[0][0]; - const lngs = coords.map((c) => c[0]); - const lats = coords.map((c) => c[1]); - map.fitBounds( - [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)], - { padding: 20 }, - ); - } catch { - /* ignore */ - } - } + try { + const coords: number[][] = + regionGeometry.type === "Polygon" + ? regionGeometry.coordinates[0] + : regionGeometry.coordinates[0][0]; + const lngs = coords.map((c) => c[0]); + const lats = coords.map((c) => c[1]); + map.fitBounds( + [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)], + { padding: 20 }, + ); + } catch { /* ignore */ } } - - // Apply bbox if already set (e.g. after component re-render) - if (bbox) applyBboxToMap(map, bbox); }); })(); @@ -418,7 +388,7 @@ function LocationSelector({
{ setQuery(e.target.value); @@ -451,35 +421,13 @@ function LocationSelector({ {r.display_name.split(",").slice(1, 3).join(",")} + {r.geojson?.type} ))}
)} - {/* Radius selector */} - {selected && ( -
- -
- {RADIUS_OPTIONS.map((r) => ( - - ))} -
-
- )} - {/* Mini map */}
- {bbox && ( + {selected?.geojson && (

- ✓ Sub-region: {radius} km around {selected!.display_name.split(",")[0]} — bbox [{bbox.map((v) => v.toFixed(4)).join(", ")}] + ✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})

)} {!selected && (

- Search for a location to select a sub-region, or leave empty to use the entire dataset. + Search for a city to use its administrative boundary for stats coverage, or leave empty to use the entire dataset.

)}
@@ -514,18 +462,18 @@ function ConfirmStep({ slug: string, name: string, countryCode: string, - bbox: [number, number, number, number] | null, + boundary: { type: string; coordinates: unknown } | null, ) => Promise; }) { const defaultSlug = region.properties.id.replace(/\//g, "-"); const [slug, setSlug] = useState(defaultSlug); const [name, setName] = useState(region.properties.name); const [countryCode, setCountryCode] = useState(""); - const [bbox, setBbox] = useState<[number, number, number, number] | null>(null); + const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null); const [loading, setLoading] = useState(false); - const handleBboxChange = useCallback( - (b: [number, number, number, number] | null) => setBbox(b), + const handleBoundaryChange = useCallback( + (b: { type: string; coordinates: unknown } | null) => setBoundary(b), [], ); @@ -572,20 +520,20 @@ function ConfirmStep({ /> - {/* Sub-region selector */} + {/* City boundary selector */}

- Select a city center and radius to ingest only that sub-area of the dataset. - Recommended for large regions (e.g. entire states). Affects OSM import, - routing tiles, and grid analysis. + Search for the city municipality to use its administrative boundary for + coverage statistics. The bounding box is still used for OSM import, routing, + and grid generation.

@@ -604,7 +552,7 @@ function ConfirmStep({