"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 ──────────────────────────────────────────────── 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[][])); } 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)); } 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; }; } interface SubPolygon { id: number; coordinates: number[][][]; selected: boolean; } function buildActiveBoundary(polys: SubPolygon[]): { type: string; coordinates: unknown } | null { const active = polys.filter((p) => p.selected); if (active.length === 0) return null; if (active.length === 1) return { type: "Polygon", coordinates: active[0].coordinates }; return { type: "MultiPolygon", coordinates: active.map((p) => p.coordinates) }; } 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 [subPolygons, setSubPolygons] = useState([]); const subPolygonsRef = useRef([]); 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; // Keep ref in sync for map click handler. useEffect(() => { subPolygonsRef.current = subPolygons; }, [subPolygons]); // When the Nominatim result changes: extract sub-polygons + notify parent + fit map. useEffect(() => { if (!selected) { setSubPolygons([]); onResultSelectRef.current?.(null); return; } const name = selected.address?.city ?? selected.address?.town ?? selected.address?.village ?? selected.address?.municipality ?? selected.display_name.split(",")[0].trim(); onResultSelectRef.current?.({ name, countryCode: (selected.address?.country_code ?? "").toUpperCase(), lat: parseFloat(selected.lat), lon: parseFloat(selected.lon), }); const geojson = selected.geojson; if (!geojson) { setSubPolygons([]); return; } const polys: SubPolygon[] = geojson.type === "Polygon" ? [{ id: 0, coordinates: geojson.coordinates as number[][][], selected: true }] : (geojson.coordinates as number[][][][]).map((coords, i) => ({ id: i, coordinates: coords, selected: true, })); setSubPolygons(polys); // Fit map to full extent of all sub-polygons. const map = mapRef.current; if (map && mapReadyRef.current) { const allCoords = polys.flatMap((sp) => sp.coordinates.flat()) as number[][]; const lngs = allCoords.map((c) => c[0]); const lats = allCoords.map((c) => c[1]); map.fitBounds( [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)], { padding: 40, duration: 500 }, ); } }, [selected]); // Whenever sub-polygons change: update map sources + propagate active boundary. useEffect(() => { onBoundaryChangeRef.current(buildActiveBoundary(subPolygons)); const map = mapRef.current; if (!map || !mapReadyRef.current) return; // Boundary source — one feature per sub-polygon with `selected` property for styling. const features = subPolygons.map((sp) => ({ type: "Feature" as const, id: sp.id, properties: { id: sp.id, selected: sp.selected }, geometry: { type: "Polygon" as const, coordinates: sp.coordinates }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (map.getSource("area-boundary") as any)?.setData({ type: "FeatureCollection", features }); // Bbox from selected sub-polygons only. const active = subPolygons.filter((sp) => sp.selected); // eslint-disable-next-line @typescript-eslint/no-explicit-any const bboxSrc = map.getSource("area-bbox") as any; if (active.length > 0) { const allCoords = active.flatMap((sp) => sp.coordinates.flat()) as number[][]; const lngs = allCoords.map((c) => c[0]); const lats = allCoords.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: {}, }); } else { bboxSrc?.setData({ type: "FeatureCollection", features: [] }); } }, [subPolygons]); // 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)}&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]); // 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; 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; // Boundary — data-driven colour based on `selected` property. map.addSource("area-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "area-boundary-fill", type: "fill", source: "area-boundary", paint: { "fill-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"], "fill-opacity": ["case", ["==", ["get", "selected"], true], 0.15, 0.05], }, }); map.addLayer({ id: "area-boundary-line", type: "line", source: "area-boundary", paint: { "line-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"], "line-width": ["case", ["==", ["get", "selected"], true], 2, 0.75], }, }); // Bbox rectangle. 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] }, }); // Click a sub-polygon to toggle it. map.on("click", "area-boundary-fill", (e) => { const id = e.features?.[0]?.properties?.id as number | undefined; if (id == null) return; setSubPolygons((prev) => prev.map((sp) => sp.id === id ? { ...sp, selected: !sp.selected } : sp), ); }); map.on("mouseenter", "area-boundary-fill", () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", "area-boundary-fill", () => { map.getCanvas().style.cursor = ""; }); // Apply pending sub-polygons if the map loaded after a selection was made. const pending = subPolygonsRef.current; if (pending.length > 0) { const features = pending.map((sp) => ({ type: "Feature" as const, id: sp.id, properties: { id: sp.id, selected: sp.selected }, geometry: { type: "Polygon" as const, coordinates: sp.coordinates }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (map.getSource("area-boundary") as any)?.setData({ type: "FeatureCollection", features }); } }); })(); return () => { cancelled = true; mapReadyRef.current = false; mapRef.current?.remove(); mapRef.current = null; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const selectedCount = subPolygons.filter((sp) => sp.selected).length; return (
{ 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) => ( ))}
)}
{subPolygons.length > 1 ? (

{selectedCount} of {subPolygons.length} parts selected {selectedCount < subPolygons.length && ( )} {" · "} click on the map to toggle

) : selected?.geojson ? (

✓ {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); 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 : ""); setMatchedRegion( geofabrikIndexRef.current ? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current) : null, ); }, [], ); 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}
}
setName(e.target.value)} placeholder="Hamburg" 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="hamburg" 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" />
{!countryCode && (

No transit scoring without a country.

)}
{geofabrikUrl ? geofabrikUrl.replace("https://download.geofabrik.de/", "") : "Select a city above"}
); }