"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared"; import { CityIngestProgress } from "@/components/city-ingest-progress"; // ─── Geofabrik region matching ──────────────────────────────────────────────── /** 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 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[][]), ); } /** 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)); } /** 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; 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; }; } const COUNTRIES = [ { code: "DE", label: "🇩🇪 Germany" }, { code: "NL", label: "🇳🇱 Netherlands" }, { code: "DK", label: "🇩🇰 Denmark" }, ]; function toSlug(name: string): string { return name .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } // ─── Nominatim geocoder + mini map ──────────────────────────────────────────── function LocationSelector({ onBoundaryChange, onResultSelect, }: { onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void; onResultSelect?: (result: { name: string; countryCode: string; lat: number; lon: number } | 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; 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, lat: parseFloat(selected.lat), lon: parseFloat(selected.lon), }); } else { onResultSelectRef.current?.(null); } }, [selected]); 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(); setResults(data.filter( (r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon", )); setShowDropdown(true); } catch { setResults([]); } finally { setSearching(false); } }, 500); return () => clearTimeout(timer); }, [query]); const applyBoundaryToMap = useCallback( (map: import("maplibre-gl").Map, geojson: NominatimResult["geojson"] | null) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any 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) { boundarySrc.setData({ type: "Feature", geometry: geojson, properties: {} }); 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]); 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 { boundarySrc.setData({ type: "FeatureCollection", features: [] }); bboxSrc.setData({ type: "FeatureCollection", features: [] }); } }, [], ); useEffect(() => { const map = mapRef.current; if (map && mapReadyRef.current) applyBoundaryToMap(map, selected?.geojson ?? null); }, [selected, applyBoundaryToMap]); useEffect(() => { if (!mapContainerRef.current) return; let cancelled = false; (async () => { const mgl = await import("maplibre-gl"); const { Protocol } = await import("pmtiles"); if (cancelled) return; try { mgl.addProtocol("pmtiles", new Protocol().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; 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 } }); // 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; mapRef.current?.remove(); mapRef.current = null; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return (
{ 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) => ( ))}
)}
{selected?.geojson ? (

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

) : (

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

)}
); } // ─── Main page ──────────────────────────────────────────────────────────────── 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 handleResultSelect = useCallback( (result: { name: string; countryCode: string; lat: number; lon: number } | null) => { if (!result) { setMatchedRegion(null); return; } setName(result.name); setSlug(toSlug(result.name)); setCountryCode(COUNTRIES.some((c) => c.code === result.countryCode) ? result.countryCode : ""); const best = geofabrikIndexRef.current ? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current) : null; setMatchedRegion(best); }, [], ); 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); } }; 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" />
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" />
{/* Country + derived OSM source */}
{!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"}
)}
); }