From 090afdaa44dd592ef354a526fa726e432ecd5d38 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 8 Mar 2026 11:05:04 +0100 Subject: [PATCH] feat: show bbox in preview --- apps/web/app/admin/cities/new/page.tsx | 668 ++++++++----------------- 1 file changed, 217 insertions(+), 451 deletions(-) diff --git a/apps/web/app/admin/cities/new/page.tsx b/apps/web/app/admin/cities/new/page.tsx index cf8a7b7..01985ac 100644 --- a/apps/web/app/admin/cities/new/page.tsx +++ b/apps/web/app/admin/cities/new/page.tsx @@ -1,192 +1,59 @@ "use client"; -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared"; import { CityIngestProgress } from "@/components/city-ingest-progress"; -type Step = "browse" | "confirm" | "ingest"; +// ─── Geofabrik region matching ──────────────────────────────────────────────── -// ─── Step indicator ─────────────────────────────────────────────────────────── +/** Ray-casting point-in-polygon for a single ring ([lng, lat][] coords). */ +function pointInRing(point: [number, number], ring: number[][]): boolean { + const [px, py] = point; + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xj = ring[j][0], yj = ring[j][1]; + if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; +} -function StepIndicator({ current }: { current: Step }) { - const steps: { key: Step; label: string }[] = [ - { key: "browse", label: "Select Region" }, - { key: "confirm", label: "Confirm" }, - { key: "ingest", label: "Processing" }, - ]; - const idx = steps.findIndex((s) => s.key === current); - return ( - +function containsPoint(geometry: GeofabrikFeature["geometry"], point: [number, number]): boolean { + if (!geometry) return false; + if (geometry.type === "Polygon") { + return pointInRing(point, geometry.coordinates[0] as number[][]); + } + return (geometry.coordinates as number[][][][]).some( + (poly) => pointInRing(point, poly[0] as number[][]), ); } -// ─── Geofabrik browser ──────────────────────────────────────────────────────── - -function GeofabrikBrowser({ - onSelect, -}: { - onSelect: (f: GeofabrikFeature) => void; -}) { - const [index, setIndex] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [query, setQuery] = useState(""); - const [parent, setParent] = useState(undefined); - - useEffect(() => { - fetch("/api/admin/geofabrik") - .then((r) => r.json()) - .then(setIndex) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }, []); - - const features = useMemo(() => { - if (!index) return []; - return index.features.filter((f) => { - const matchesParent = parent - ? f.properties.parent === parent - : !f.properties.parent; - const matchesQuery = query - ? f.properties.name.toLowerCase().includes(query.toLowerCase()) || - f.properties.id.toLowerCase().includes(query.toLowerCase()) - : true; - return matchesParent && matchesQuery && f.properties.urls?.pbf; - }); - }, [index, parent, query]); - - const parentFeature = index?.features.find( - (f) => f.properties.id === parent, - ); - const grandParent = parentFeature - ? index?.features.find( - (f) => f.properties.id === parentFeature.properties.parent, - ) - : null; - - if (loading) - return ( -
- Loading Geofabrik region index… -
- ); - if (error) - return ( -
- Error loading index: {error} -
- ); - - return ( -
-

Select a Region

- -
- - {grandParent && ( - <> - - - - )} - {parentFeature && ( - <> - - {parentFeature.properties.name} - - )} -
- - setQuery(e.target.value)} - className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm mb-4 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" - /> - -
- {features.length === 0 ? ( -

No regions found.

- ) : ( - features.map((f) => { - const hasChildren = index!.features.some( - (c) => c.properties.parent === f.properties.id, - ); - return ( -
-
-

- {f.properties.name} -

-

{f.properties.id}

-
-
- {hasChildren && ( - - )} - -
-
- ); - }) - )} -
-
- ); +/** Bounding-box area as a proxy for region size (smaller = more specific). */ +function bboxArea(geometry: GeofabrikFeature["geometry"]): number { + if (!geometry) return Infinity; + const coords: number[][] = + geometry.type === "Polygon" + ? (geometry.coordinates as number[][][]).flat() + : (geometry.coordinates as number[][][][]).flat(2); + const lngs = coords.map((c) => c[0]); + const lats = coords.map((c) => c[1]); + return (Math.max(...lngs) - Math.min(...lngs)) * (Math.max(...lats) - Math.min(...lats)); } -// ─── Nominatim polygon geocoder + mini map ─────────────────────────────────── +/** Returns the smallest Geofabrik region whose polygon contains [lon, lat]. */ +function findBestRegion(lat: number, lon: number, index: GeofabrikIndex): GeofabrikFeature | null { + const point: [number, number] = [lon, lat]; + const candidates = index.features.filter( + (f) => f.properties.urls?.pbf && f.geometry && containsPoint(f.geometry, point), + ); + if (candidates.length === 0) return null; + candidates.sort((a, b) => bboxArea(a.geometry) - bboxArea(b.geometry)); + return candidates[0]; +} + +// ─── Nominatim types + helpers ──────────────────────────────────────────────── interface NominatimResult { place_id: number; @@ -206,6 +73,12 @@ interface NominatimResult { }; } +const COUNTRIES = [ + { code: "DE", label: "🇩🇪 Germany" }, + { code: "NL", label: "🇳🇱 Netherlands" }, + { code: "DK", label: "🇩🇰 Denmark" }, +]; + function toSlug(name: string): string { return name .toLowerCase() @@ -215,14 +88,14 @@ function toSlug(name: string): string { .replace(/^-|-$/g, ""); } +// ─── Nominatim geocoder + mini map ──────────────────────────────────────────── + function LocationSelector({ - regionGeometry, onBoundaryChange, onResultSelect, }: { - regionGeometry: GeofabrikFeature["geometry"]; onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void; - onResultSelect?: (result: { name: string; countryCode: string } | null) => void; + onResultSelect?: (result: { name: string; countryCode: string; lat: number; lon: number } | null) => void; }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -238,7 +111,6 @@ function LocationSelector({ const onResultSelectRef = useRef(onResultSelect); onResultSelectRef.current = onResultSelect; - // Notify parent when selection changes. useEffect(() => { onBoundaryChangeRef.current(selected?.geojson ?? null); if (selected) { @@ -249,18 +121,19 @@ function LocationSelector({ selected.address?.municipality ?? selected.display_name.split(",")[0].trim(); const countryCode = (selected.address?.country_code ?? "").toUpperCase(); - onResultSelectRef.current?.({ name, countryCode }); + onResultSelectRef.current?.({ + name, + countryCode, + lat: parseFloat(selected.lat), + lon: parseFloat(selected.lon), + }); } else { onResultSelectRef.current?.(null); } }, [selected]); - // Debounced Nominatim search — request polygon_geojson + featuretype=settlement useEffect(() => { - if (query.length < 2) { - setResults([]); - return; - } + if (query.length < 2) { setResults([]); return; } const timer = setTimeout(async () => { setSearching(true); try { @@ -269,10 +142,8 @@ function LocationSelector({ { headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } }, ); const data: NominatimResult[] = await res.json(); - // Keep polygon results at municipality level or finer (rank >= 10). setResults(data.filter( - (r) => r.place_rank >= 10 && - (r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon"), + (r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon", )); setShowDropdown(true); } catch { @@ -284,15 +155,15 @@ function LocationSelector({ return () => clearTimeout(timer); }, [query]); - // 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-boundary") as any; - if (!src) return; + const boundarySrc = map.getSource("area-boundary") as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bboxSrc = map.getSource("area-bbox") as any; + if (!boundarySrc || !bboxSrc) return; if (geojson) { - src.setData({ type: "Feature", geometry: geojson, properties: {} }); - // Fit to boundary bbox — flatten all rings/polygons to get full extent + boundarySrc.setData({ type: "Feature", geometry: geojson, properties: {} }); try { const coords: number[][] = geojson.type === "Polygon" @@ -300,43 +171,42 @@ function LocationSelector({ : (geojson.coordinates as number[][][][]).flat(2); 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 }, - ); + const [minLng, minLat, maxLng, maxLat] = [ + Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats), + ]; + bboxSrc.setData({ + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [[ + [minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, maxLat], [minLng, minLat], + ]], + }, + properties: {}, + }); + map.fitBounds([minLng, minLat, maxLng, maxLat], { padding: 40, duration: 500 }); } catch { /* ignore */ } } else { - src.setData({ type: "FeatureCollection", features: [] }); + boundarySrc.setData({ type: "FeatureCollection", features: [] }); + bboxSrc.setData({ type: "FeatureCollection", features: [] }); } }, [], ); - // Update mini map when selection changes useEffect(() => { const map = mapRef.current; if (map && mapReadyRef.current) applyBoundaryToMap(map, selected?.geojson ?? null); }, [selected, applyBoundaryToMap]); - // Initialize mini map useEffect(() => { if (!mapContainerRef.current) return; let cancelled = false; - (async () => { const mgl = await import("maplibre-gl"); const { Protocol } = await import("pmtiles"); - if (cancelled) return; - - // Register pmtiles protocol (idempotent) - try { - const p = new Protocol(); - mgl.addProtocol("pmtiles", p.tile); - } catch { - // Already registered - } - + try { mgl.addProtocol("pmtiles", new Protocol().tile); } catch { /* already registered */ } const map = new mgl.Map({ container: mapContainerRef.current!, style: "/tiles/style.json", @@ -344,67 +214,18 @@ function LocationSelector({ zoom: 3, }); mapRef.current = map; - map.on("load", () => { if (cancelled) return; mapReadyRef.current = true; + map.addSource("area-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); + map.addLayer({ id: "area-boundary-fill", type: "fill", source: "area-boundary", paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 } }); + map.addLayer({ id: "area-boundary-line", type: "line", source: "area-boundary", paint: { "line-color": "#2563eb", "line-width": 2 } }); - // Region outline (Geofabrik polygon) - map.addSource("area-region", { - type: "geojson", - data: regionGeometry - ? { type: "Feature", geometry: regionGeometry, properties: {} } - : { type: "FeatureCollection", features: [] }, - }); - map.addLayer({ - id: "area-region-fill", - type: "fill", - source: "area-region", - paint: { "fill-color": "#64748b", "fill-opacity": 0.1 }, - }); - map.addLayer({ - id: "area-region-line", - type: "line", - source: "area-region", - paint: { "line-color": "#64748b", "line-width": 1.5, "line-dasharray": [3, 2] }, - }); - - // Selected city boundary polygon - map.addSource("area-boundary", { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - map.addLayer({ - id: "area-boundary-fill", - type: "fill", - source: "area-boundary", - paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 }, - }); - map.addLayer({ - id: "area-boundary-line", - type: "line", - source: "area-boundary", - paint: { "line-color": "#2563eb", "line-width": 2 }, - }); - - // Fit to region if available - if (regionGeometry) { - try { - const coords: number[][] = - regionGeometry.type === "Polygon" - ? regionGeometry.coordinates.flat() - : regionGeometry.coordinates.flat(2); - 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 */ } - } + // Bbox rectangle — shown on top of the boundary polygon + map.addSource("area-bbox", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); + map.addLayer({ id: "area-bbox-line", type: "line", source: "area-bbox", paint: { "line-color": "#f59e0b", "line-width": 1.5, "line-dasharray": [4, 3] } }); }); })(); - return () => { cancelled = true; mapReadyRef.current = false; @@ -415,7 +236,6 @@ function LocationSelector({ return (
- {/* Geocoder */}
results.length > 0 && setShowDropdown(true)} className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" /> - {searching && ( - Searching… - )} + {searching && Searching…}
- {showDropdown && results.length > 0 && (
{results.map((r) => ( ))}
)}
- - {/* Mini map */} -
- - {selected?.geojson && ( +
+ {selected?.geojson ? (

✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})

- )} - {!selected && ( + ) : (

Search for a city to set its administrative boundary and auto-fill the fields below.

@@ -481,71 +282,119 @@ function LocationSelector({ ); } -// ─── Confirm step ───────────────────────────────────────────────────────────── +// ─── Main page ──────────────────────────────────────────────────────────────── -function ConfirmStep({ - region, - onBack, - onConfirm, -}: { - region: GeofabrikFeature; - onBack: () => void; - onConfirm: ( - slug: string, - name: string, - countryCode: string, - 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); +export default function AddCityPage() { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); const [countryCode, setCountryCode] = useState(""); const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null); + const [matchedRegion, setMatchedRegion] = useState(null); + const [jobId, setJobId] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const geofabrikIndexRef = useRef(null); + + // Fetch Geofabrik index eagerly so it's ready when the user picks a city. + useEffect(() => { + fetch("/api/admin/geofabrik") + .then((r) => r.json()) + .then((data: GeofabrikIndex) => { geofabrikIndexRef.current = data; }) + .catch(() => {}); + }, []); + + const geofabrikUrl = matchedRegion?.properties.urls?.pbf ?? undefined; const handleBoundaryChange = useCallback( (b: { type: string; coordinates: unknown } | null) => setBoundary(b), [], ); - const KNOWN_COUNTRIES = ["DE", "NL", "DK"]; const handleResultSelect = useCallback( - (result: { name: string; countryCode: string } | null) => { - if (!result) return; + (result: { name: string; countryCode: string; lat: number; lon: number } | null) => { + if (!result) { setMatchedRegion(null); return; } setName(result.name); setSlug(toSlug(result.name)); - setCountryCode(KNOWN_COUNTRIES.includes(result.countryCode) ? result.countryCode : ""); + setCountryCode(COUNTRIES.some((c) => c.code === result.countryCode) ? result.countryCode : ""); + const best = geofabrikIndexRef.current + ? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current) + : null; + setMatchedRegion(best); }, [], ); - return ( -
-

Confirm City Details

+ const handleSubmit = async () => { + if (!slug || !name || !geofabrikUrl) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/cities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug, name, countryCode, geofabrikUrl, + ...(boundary ? { boundary } : {}), + }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Failed to start ingestion"); + return; + } + const data = await res.json(); + setJobId(data.jobId); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }; -
- {/* City boundary selector — at the top so it can auto-fill the fields below */} + if (jobId) { + return ( +
+
+ ← Back +

Adding {name}…

+
+ +
+ ); + } + + return ( +
+

Add City

+

+ Search for a city to configure and start ingestion. +

+ + {error && ( +
{error}
+ )} + +
+ {/* Geocoder + map */}
+ {/* Name + Slug */}
- + setName(e.target.value)} + placeholder="Amsterdam" className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" />
@@ -555,138 +404,55 @@ function ConfirmStep({ - setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-")) - } + onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))} + placeholder="amsterdam" className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" />
-
- - setCountryCode(e.target.value)} + className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" + > + + {COUNTRIES.map((c) => ( + + ))} + + {!countryCode && ( +

No transit scoring without a country.

+ )} +
+
+ + {geofabrikUrl ? ( +
+ {geofabrikUrl.replace("https://download.geofabrik.de/", "")} +
+ ) : ( +
+ {matchedRegion === null && name ? "No Geofabrik region found" : "Select a city above"} +
+ )} +
+
+ +
+
- -
-

Source URL

-

- {region.properties.urls.pbf} -

-
-
- -
- -
); } - -// ─── Progress step ──────────────────────────────────────────────────────────── - - - -// ─── Main page ──────────────────────────────────────────────────────────────── - -export default function AddCityPage() { - const [step, setStep] = useState("browse"); - const [selected, setSelected] = useState(null); - const [jobId, setJobId] = useState(null); - const [ingestError, setIngestError] = useState(null); - - const handleConfirm = async ( - slug: string, - name: string, - countryCode: string, - boundary: { type: string; coordinates: unknown } | null, - ) => { - setIngestError(null); - try { - const res = await fetch("/api/admin/cities", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - slug, - name, - countryCode, - geofabrikUrl: selected!.properties.urls.pbf, - ...(boundary ? { boundary } : {}), - }), - }); - if (!res.ok) { - const data = await res.json(); - setIngestError(data.error ?? "Failed to start ingestion"); - return; - } - const data = await res.json(); - setJobId(data.jobId); - setStep("ingest"); - } catch (e) { - setIngestError(String(e)); - } - }; - - return ( -
-

Add City

-

- Select an OpenStreetMap region to import for 15-minute city analysis. -

- - - - {ingestError && ( -
- {ingestError} -
- )} - - {step === "browse" && ( - { - setSelected(f); - setStep("confirm"); - }} - /> - )} - {step === "confirm" && selected && ( - setStep("browse")} - onConfirm={handleConfirm} - /> - )} - {step === "ingest" && } -
- ); -}