"use client"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared"; import { useJobProgress } from "@/hooks/use-job-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 geocoder + radius selector + mini map ───────────────────────── interface NominatimResult { place_id: number; display_name: string; lat: string; 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: {}, }; } function LocationSelector({ regionGeometry, onBboxChange, }: { regionGeometry: GeofabrikFeature["geometry"]; onBboxChange: (bbox: [number, number, number, number] | 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 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 useEffect(() => { onBboxChangeRef.current(bbox); }, [bbox]); // Debounced Nominatim search 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)}&format=json&limit=6&addressdetails=0`, { headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } }, ); const data: NominatimResult[] = await res.json(); setResults(data); setShowDropdown(true); } catch { setResults([]); } finally { setSearching(false); } }, 500); return () => clearTimeout(timer); }, [query]); // Apply bbox to mini map const applyBboxToMap = useCallback( (map: import("maplibre-gl").Map, b: [number, number, number, number] | null) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const src = map.getSource("area-bbox") 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 }); } else { src.setData({ type: "FeatureCollection", features: [] }); } }, [], ); // Update mini map when bbox changes useEffect(() => { const map = mapRef.current; if (map && mapReadyRef.current) applyBboxToMap(map, bbox); }, [bbox, applyBboxToMap]); // 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 sub-area bbox map.addSource("area-bbox", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addLayer({ id: "area-bbox-fill", type: "fill", source: "area-bbox", paint: { "fill-color": "#2563eb", "fill-opacity": 0.15 }, }); map.addLayer({ id: "area-bbox-line", type: "line", source: "area-bbox", 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 */ } } } // Apply bbox if already set (e.g. after component re-render) if (bbox) applyBboxToMap(map, bbox); }); })(); 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([]); } }} 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) => ( ))}
)}
{/* Radius selector */} {selected && (
{RADIUS_OPTIONS.map((r) => ( ))}
)} {/* Mini map */}
{bbox && (

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

)} {!selected && (

Search for a location to select a sub-region, or leave empty to use the entire dataset.

)}
); } // ─── Confirm step ───────────────────────────────────────────────────────────── function ConfirmStep({ region, onBack, onConfirm, }: { region: GeofabrikFeature; onBack: () => void; onConfirm: ( slug: string, name: string, countryCode: string, bbox: [number, number, number, number] | 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 [loading, setLoading] = useState(false); const handleBboxChange = useCallback( (b: [number, number, number, number] | null) => setBbox(b), [], ); return (

Confirm City Details

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" />
{/* Sub-region 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.

Source URL

{region.properties.urls.pbf}

); } // ─── Progress step ──────────────────────────────────────────────────────────── function ProgressStep({ jobId }: { jobId: string | null }) { const { stages, overall, error } = useJobProgress(jobId); return (

Processing City Data

    {stages.map((stage) => (
  1. {stage.label}

    {stage.status === "active" && ( <>

    {stage.message}

    )} {stage.status === "failed" && error && (

    {error}

    )}
  2. ))}
{overall === "completed" && (
✓ City ingestion complete!{" "} Return to dashboard {" "} or{" "} view on map .
)} {overall === "failed" && (
✗ Ingestion failed: {error}.{" "} Return to dashboard .
)}
); } function StageIcon({ status }: { status: StageStatus["status"] }) { if (status === "completed") return ( ); if (status === "failed") return ( ); if (status === "active") return ( ); return ( ); } import type { StageStatus } from "@/hooks/use-job-progress"; // ─── 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, bbox: [number, number, number, number] | 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, ...(bbox ? { bbox } : {}), }), }); 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" && }
); }