"use client"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared"; import { CityIngestProgress } from "@/components/city-ingest-progress"; type Step = "browse" | "confirm" | "ingest"; // ─── Step indicator ─────────────────────────────────────────────────────────── 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 ( ); } // ─── 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 && ( )}
); }) )}
); } // ─── Nominatim polygon geocoder + mini map ─────────────────────────────────── interface NominatimResult { place_id: number; display_name: string; lat: string; lon: string; type: string; class: string; place_rank: number; geojson?: { type: string; coordinates: unknown }; address?: { city?: string; town?: string; village?: string; municipality?: string; country_code?: string; }; } function toSlug(name: string): string { return name .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } function LocationSelector({ regionGeometry, onBoundaryChange, onResultSelect, }: { regionGeometry: GeofabrikFeature["geometry"]; onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void; onResultSelect?: (result: { name: string; countryCode: string } | null) => void; }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [selected, setSelected] = useState(null); const [showDropdown, setShowDropdown] = useState(false); const [searching, setSearching] = useState(false); const mapContainerRef = useRef(null); const mapRef = useRef(null); const mapReadyRef = useRef(false); const onBoundaryChangeRef = useRef(onBoundaryChange); onBoundaryChangeRef.current = onBoundaryChange; const onResultSelectRef = useRef(onResultSelect); onResultSelectRef.current = onResultSelect; // Notify parent when selection changes. useEffect(() => { onBoundaryChangeRef.current(selected?.geojson ?? null); if (selected) { const name = selected.address?.city ?? selected.address?.town ?? selected.address?.village ?? selected.address?.municipality ?? selected.display_name.split(",")[0].trim(); const countryCode = (selected.address?.country_code ?? "").toUpperCase(); onResultSelectRef.current?.({ name, countryCode }); } else { onResultSelectRef.current?.(null); } }, [selected]); // Debounced Nominatim search — request polygon_geojson + featuretype=settlement useEffect(() => { if (query.length < 2) { setResults([]); return; } const timer = setTimeout(async () => { setSearching(true); try { const res = await fetch( `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&polygon_geojson=1&addressdetails=1&format=json&limit=8`, { 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"), )); setShowDropdown(true); } catch { setResults([]); } finally { setSearching(false); } }, 500); 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; if (geojson) { src.setData({ type: "Feature", geometry: geojson, properties: {} }); // Fit to boundary bbox — flatten all rings/polygons to get full extent try { const coords: number[][] = geojson.type === "Polygon" ? (geojson.coordinates as number[][][]).flat() : (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 }, ); } catch { /* ignore */ } } else { src.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 } const map = new mgl.Map({ container: mapContainerRef.current!, style: "/tiles/style.json", center: [10, 51], zoom: 3, }); mapRef.current = map; map.on("load", () => { if (cancelled) return; mapReadyRef.current = true; // 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 */ } } }); })(); return () => { cancelled = true; mapReadyRef.current = false; mapRef.current?.remove(); mapRef.current = null; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return (
{/* Geocoder */}
{ setQuery(e.target.value); if (!e.target.value) { setSelected(null); setResults([]); onResultSelectRef.current?.(null); } }} onFocus={() => 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… )}
{showDropdown && results.length > 0 && (
{results.map((r) => ( ))}
)}
{/* Mini map */}
{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.

)}
); } // ─── Confirm step ───────────────────────────────────────────────────────────── 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); const [countryCode, setCountryCode] = useState(""); const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null); const [loading, setLoading] = useState(false); const handleBoundaryChange = useCallback( (b: { type: string; coordinates: unknown } | null) => setBoundary(b), [], ); const handleResultSelect = useCallback( (result: { name: string; countryCode: string } | null) => { if (!result) return; setName(result.name); setSlug(toSlug(result.name)); setCountryCode(result.countryCode); }, [], ); return (

Confirm City Details

{/* City boundary selector — at the top so it can auto-fill the fields below */}
setName(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" />
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-")) } 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.toUpperCase())} 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" placeholder="DE" /> {!countryCode && (

No transit scoring without a country code.

)}

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