Compare commits

...

5 commits

2 changed files with 325 additions and 474 deletions

View file

@ -1,192 +1,50 @@
"use client"; "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 type { GeofabrikFeature, GeofabrikIndex } from "@transportationer/shared";
import { CityIngestProgress } from "@/components/city-ingest-progress"; import { CityIngestProgress } from "@/components/city-ingest-progress";
type Step = "browse" | "confirm" | "ingest"; // ─── Geofabrik region matching ────────────────────────────────────────────────
// ─── Step indicator ─────────────────────────────────────────────────────────── function pointInRing(point: [number, number], ring: number[][]): boolean {
const [px, py] = point;
function StepIndicator({ current }: { current: Step }) { let inside = false;
const steps: { key: Step; label: string }[] = [ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
{ key: "browse", label: "Select Region" }, const xi = ring[i][0], yi = ring[i][1];
{ key: "confirm", label: "Confirm" }, const xj = ring[j][0], yj = ring[j][1];
{ key: "ingest", label: "Processing" }, if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
]; }
const idx = steps.findIndex((s) => s.key === current); return inside;
return (
<nav className="flex items-center gap-4 mb-8">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<span
className={`w-6 h-6 rounded-full text-xs flex items-center justify-center font-medium ${
i < idx
? "bg-brand-600 text-white"
: i === idx
? "bg-brand-600 text-white ring-2 ring-brand-300"
: "bg-gray-200 text-gray-500"
}`}
>
{i < idx ? "✓" : i + 1}
</span>
<span
className={`text-sm font-medium ${i === idx ? "text-gray-900" : "text-gray-500"}`}
>
{s.label}
</span>
{i < steps.length - 1 && (
<span className="text-gray-300 mx-2"></span>
)}
</div>
))}
</nav>
);
} }
// ─── Geofabrik browser ──────────────────────────────────────────────────────── function containsPoint(geometry: GeofabrikFeature["geometry"], point: [number, number]): boolean {
if (!geometry) return false;
function GeofabrikBrowser({ if (geometry.type === "Polygon") return pointInRing(point, geometry.coordinates[0] as number[][]);
onSelect, return (geometry.coordinates as number[][][][]).some((poly) => pointInRing(point, poly[0] as number[][]));
}: {
onSelect: (f: GeofabrikFeature) => void;
}) {
const [index, setIndex] = useState<GeofabrikIndex | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [parent, setParent] = useState<string | undefined>(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 (
<div className="card py-12 text-center text-gray-500">
Loading Geofabrik region index
</div>
);
if (error)
return (
<div className="card py-8 text-center text-red-600">
Error loading index: {error}
</div>
);
return (
<div className="card">
<h2 className="text-lg font-semibold mb-4">Select a Region</h2>
<div className="flex items-center gap-2 text-sm mb-4 flex-wrap">
<button
onClick={() => setParent(undefined)}
className="text-brand-600 hover:underline"
>
All regions
</button>
{grandParent && (
<>
<span className="text-gray-400"></span>
<button
onClick={() => setParent(grandParent.properties.id)}
className="text-brand-600 hover:underline"
>
{grandParent.properties.name}
</button>
</>
)}
{parentFeature && (
<>
<span className="text-gray-400"></span>
<span className="font-medium">{parentFeature.properties.name}</span>
</>
)}
</div>
<input
type="search"
placeholder="Search regions…"
value={query}
onChange={(e) => 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"
/>
<div className="divide-y divide-gray-100 max-h-80 overflow-y-auto rounded border border-gray-200">
{features.length === 0 ? (
<p className="text-sm text-gray-500 py-4 px-3">No regions found.</p>
) : (
features.map((f) => {
const hasChildren = index!.features.some(
(c) => c.properties.parent === f.properties.id,
);
return (
<div
key={f.properties.id}
className="flex items-center justify-between px-3 py-2.5 hover:bg-gray-50"
>
<div>
<p className="text-sm font-medium text-gray-900">
{f.properties.name}
</p>
<p className="text-xs text-gray-400">{f.properties.id}</p>
</div>
<div className="flex gap-2">
{hasChildren && (
<button
onClick={() => {
setParent(f.properties.id);
setQuery("");
}}
className="btn-secondary text-xs py-1"
>
Browse
</button>
)}
<button
onClick={() => onSelect(f)}
className="btn-primary text-xs py-1"
>
Select
</button>
</div>
</div>
);
})
)}
</div>
</div>
);
} }
// ─── Nominatim polygon geocoder + mini map ─────────────────────────────────── 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 { interface NominatimResult {
place_id: number; place_id: number;
@ -206,6 +64,25 @@ interface NominatimResult {
}; };
} }
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 { function toSlug(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
@ -215,52 +92,119 @@ function toSlug(name: string): string {
.replace(/^-|-$/g, ""); .replace(/^-|-$/g, "");
} }
// ─── Nominatim geocoder + mini map ────────────────────────────────────────────
function LocationSelector({ function LocationSelector({
regionGeometry,
onBoundaryChange, onBoundaryChange,
onResultSelect, onResultSelect,
}: { }: {
regionGeometry: GeofabrikFeature["geometry"];
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void; 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 [query, setQuery] = useState("");
const [results, setResults] = useState<NominatimResult[]>([]); const [results, setResults] = useState<NominatimResult[]>([]);
const [selected, setSelected] = useState<NominatimResult | null>(null); const [selected, setSelected] = useState<NominatimResult | null>(null);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [subPolygons, setSubPolygons] = useState<SubPolygon[]>([]);
const subPolygonsRef = useRef<SubPolygon[]>([]);
const mapContainerRef = useRef<HTMLDivElement>(null); const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<import("maplibre-gl").Map | null>(null); const mapRef = useRef<import("maplibre-gl").Map | null>(null);
const mapReadyRef = useRef(false); const mapReadyRef = useRef(false);
const onBoundaryChangeRef = useRef(onBoundaryChange); const onBoundaryChangeRef = useRef(onBoundaryChange);
onBoundaryChangeRef.current = onBoundaryChange; onBoundaryChangeRef.current = onBoundaryChange;
const onResultSelectRef = useRef(onResultSelect); const onResultSelectRef = useRef(onResultSelect);
onResultSelectRef.current = onResultSelect; onResultSelectRef.current = onResultSelect;
// Notify parent when selection changes. // 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(() => { useEffect(() => {
onBoundaryChangeRef.current(selected?.geojson ?? null); if (!selected) {
if (selected) { setSubPolygons([]);
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); 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]); }, [selected]);
// Debounced Nominatim search — request polygon_geojson + featuretype=settlement // Whenever sub-polygons change: update map sources + propagate active boundary.
useEffect(() => { useEffect(() => {
if (query.length < 2) { onBoundaryChangeRef.current(buildActiveBoundary(subPolygons));
setResults([]);
return; 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 () => { const timer = setTimeout(async () => {
setSearching(true); setSearching(true);
try { try {
@ -269,10 +213,8 @@ function LocationSelector({
{ headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } }, { headers: { "User-Agent": "Transportationer/1.0 (15-minute city analyzer)" } },
); );
const data: NominatimResult[] = await res.json(); const data: NominatimResult[] = await res.json();
// Keep polygon results at municipality level or finer (rank >= 10).
setResults(data.filter( setResults(data.filter(
(r) => r.place_rank >= 10 && (r) => r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon",
(r.geojson?.type === "Polygon" || r.geojson?.type === "MultiPolygon"),
)); ));
setShowDropdown(true); setShowDropdown(true);
} catch { } catch {
@ -284,58 +226,15 @@ function LocationSelector({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [query]); }, [query]);
// Apply boundary polygon to mini map // Initialize 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(() => { useEffect(() => {
if (!mapContainerRef.current) return; if (!mapContainerRef.current) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const mgl = await import("maplibre-gl"); const mgl = await import("maplibre-gl");
const { Protocol } = await import("pmtiles"); const { Protocol } = await import("pmtiles");
if (cancelled) return; if (cancelled) return;
try { mgl.addProtocol("pmtiles", new Protocol().tile); } catch { /* already registered */ }
// Register pmtiles protocol (idempotent)
try {
const p = new Protocol();
mgl.addProtocol("pmtiles", p.tile);
} catch {
// Already registered
}
const map = new mgl.Map({ const map = new mgl.Map({
container: mapContainerRef.current!, container: mapContainerRef.current!,
@ -349,62 +248,54 @@ function LocationSelector({
if (cancelled) return; if (cancelled) return;
mapReadyRef.current = true; mapReadyRef.current = true;
// Region outline (Geofabrik polygon) // Boundary — data-driven colour based on `selected` property.
map.addSource("area-region", { map.addSource("area-boundary", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
type: "geojson", map.addLayer({
data: regionGeometry id: "area-boundary-fill", type: "fill", source: "area-boundary",
? { type: "Feature", geometry: regionGeometry, properties: {} } paint: {
: { type: "FeatureCollection", features: [] }, "fill-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"],
"fill-opacity": ["case", ["==", ["get", "selected"], true], 0.15, 0.05],
},
}); });
map.addLayer({ map.addLayer({
id: "area-region-fill", id: "area-boundary-line", type: "line", source: "area-boundary",
type: "fill", paint: {
source: "area-region", "line-color": ["case", ["==", ["get", "selected"], true], "#2563eb", "#94a3b8"],
paint: { "fill-color": "#64748b", "fill-opacity": 0.1 }, "line-width": ["case", ["==", ["get", "selected"], true], 2, 0.75],
}); },
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 // Bbox rectangle.
map.addSource("area-boundary", { map.addSource("area-bbox", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({ map.addLayer({
id: "area-boundary-fill", id: "area-bbox-line", type: "line", source: "area-bbox",
type: "fill", paint: { "line-color": "#f59e0b", "line-width": 1.5, "line-dasharray": [4, 3] },
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 // Click a sub-polygon to toggle it.
if (regionGeometry) { map.on("click", "area-boundary-fill", (e) => {
try { const id = e.features?.[0]?.properties?.id as number | undefined;
const coords: number[][] = if (id == null) return;
regionGeometry.type === "Polygon" setSubPolygons((prev) =>
? regionGeometry.coordinates.flat() prev.map((sp) => sp.id === id ? { ...sp, selected: !sp.selected } : sp),
: regionGeometry.coordinates.flat(2); );
const lngs = coords.map((c) => c[0]); });
const lats = coords.map((c) => c[1]); map.on("mouseenter", "area-boundary-fill", () => { map.getCanvas().style.cursor = "pointer"; });
map.fitBounds( map.on("mouseleave", "area-boundary-fill", () => { map.getCanvas().style.cursor = ""; });
[Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
{ padding: 20 }, // Apply pending sub-polygons if the map loaded after a selection was made.
); const pending = subPolygonsRef.current;
} catch { /* ignore */ } 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 () => { return () => {
cancelled = true; cancelled = true;
mapReadyRef.current = false; mapReadyRef.current = false;
@ -413,9 +304,10 @@ function LocationSelector({
}; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const selectedCount = subPolygons.filter((sp) => sp.selected).length;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Geocoder */}
<div className="relative"> <div className="relative">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input <input
@ -424,35 +316,24 @@ function LocationSelector({
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
if (!e.target.value) { setSelected(null); setResults([]); onResultSelectRef.current?.(null); } if (!e.target.value) { setSelected(null); setResults([]); }
}} }}
onFocus={() => results.length > 0 && setShowDropdown(true)} 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" 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 && <span className="text-xs text-gray-400 whitespace-nowrap">Searching</span>}
<span className="text-xs text-gray-400 whitespace-nowrap">Searching</span>
)}
</div> </div>
{showDropdown && results.length > 0 && ( {showDropdown && results.length > 0 && (
<div className="absolute z-20 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-56 overflow-y-auto"> <div className="absolute z-20 left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-56 overflow-y-auto">
{results.map((r) => ( {results.map((r) => (
<button <button
key={r.place_id} key={r.place_id}
type="button" type="button"
onClick={() => { onClick={() => { setSelected(r); setQuery(r.display_name.split(",").slice(0, 3).join(", ")); setShowDropdown(false); }}
setSelected(r);
setQuery(r.display_name.split(",").slice(0, 3).join(", "));
setShowDropdown(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-0" className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-0"
> >
<span className="font-medium text-gray-800"> <span className="font-medium text-gray-800">{r.display_name.split(",")[0]}</span>
{r.display_name.split(",")[0]} <span className="text-gray-400 ml-1 text-xs">{r.display_name.split(",").slice(1, 3).join(",")}</span>
</span>
<span className="text-gray-400 ml-1 text-xs">
{r.display_name.split(",").slice(1, 3).join(",")}
</span>
<span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span> <span className="ml-2 text-xs text-blue-500">{r.geojson?.type}</span>
</button> </button>
))} ))}
@ -460,19 +341,28 @@ function LocationSelector({
)} )}
</div> </div>
{/* Mini map */} <div ref={mapContainerRef} className="w-full rounded-md border border-gray-200 overflow-hidden" style={{ height: 220 }} />
<div
ref={mapContainerRef}
className="w-full rounded-md border border-gray-200 overflow-hidden"
style={{ height: 220 }}
/>
{selected?.geojson && ( {subPolygons.length > 1 ? (
<p className="text-xs text-green-700"> <p className="text-xs text-gray-500">
Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type}) {selectedCount} of {subPolygons.length} parts selected
{selectedCount < subPolygons.length && (
<button
type="button"
onClick={() => setSubPolygons((prev) => prev.map((sp) => ({ ...sp, selected: true })))}
className="ml-2 text-brand-600 hover:underline"
>
select all
</button>
)}
{" · "}
<span className="text-gray-400">click on the map to toggle</span>
</p> </p>
)} ) : selected?.geojson ? (
{!selected && ( <p className="text-xs text-green-700">
{selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
</p>
) : (
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Search for a city to set its administrative boundary and auto-fill the fields below. Search for a city to set its administrative boundary and auto-fill the fields below.
</p> </p>
@ -481,28 +371,27 @@ function LocationSelector({
); );
} }
// ─── Confirm step ───────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
function ConfirmStep({ export default function AddCityPage() {
region, const [name, setName] = useState("");
onBack, const [slug, setSlug] = useState("");
onConfirm,
}: {
region: GeofabrikFeature;
onBack: () => void;
onConfirm: (
slug: string,
name: string,
countryCode: string,
boundary: { type: string; coordinates: unknown } | null,
) => Promise<void>;
}) {
const defaultSlug = region.properties.id.replace(/\//g, "-");
const [slug, setSlug] = useState(defaultSlug);
const [name, setName] = useState(region.properties.name);
const [countryCode, setCountryCode] = useState(""); const [countryCode, setCountryCode] = useState("");
const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null); const [boundary, setBoundary] = useState<{ type: string; coordinates: unknown } | null>(null);
const [matchedRegion, setMatchedRegion] = useState<GeofabrikFeature | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const geofabrikIndexRef = useRef<GeofabrikIndex | null>(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( const handleBoundaryChange = useCallback(
(b: { type: string; coordinates: unknown } | null) => setBoundary(b), (b: { type: string; coordinates: unknown } | null) => setBoundary(b),
@ -510,41 +399,82 @@ function ConfirmStep({
); );
const handleResultSelect = useCallback( const handleResultSelect = useCallback(
(result: { name: string; countryCode: string } | null) => { (result: { name: string; countryCode: string; lat: number; lon: number } | null) => {
if (!result) return; if (!result) { setMatchedRegion(null); return; }
setName(result.name); setName(result.name);
setSlug(toSlug(result.name)); setSlug(toSlug(result.name));
setCountryCode(result.countryCode); setCountryCode(COUNTRIES.some((c) => c.code === result.countryCode) ? result.countryCode : "");
setMatchedRegion(
geofabrikIndexRef.current
? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current)
: null,
);
}, },
[], [],
); );
return ( const handleSubmit = async () => {
<div className="card max-w-2xl"> if (!slug || !name || !geofabrikUrl) return;
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2> 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);
}
};
<div className="space-y-4"> if (jobId) {
{/* City boundary selector — at the top so it can auto-fill the fields below */} return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<a href="/admin" className="text-sm text-gray-500 hover:text-gray-700"> Back</a>
<h1 className="text-2xl font-bold text-gray-900">Adding {name}</h1>
</div>
<CityIngestProgress jobId={jobId} />
</div>
);
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add City</h1>
<p className="text-sm text-gray-500 mb-6">Search for a city to configure and start ingestion.</p>
{error && <div className="mb-4 p-3 bg-red-50 rounded text-sm text-red-700">{error}</div>}
<div className="card space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
City Boundary{" "} City Boundary{" "}
<span className="text-gray-400 font-normal">(optional auto-fills name, slug &amp; country)</span> <span className="text-gray-400 font-normal">(optional auto-fills fields below)</span>
</label> </label>
<LocationSelector <LocationSelector onBoundaryChange={handleBoundaryChange} onResultSelect={handleResultSelect} />
regionGeometry={region.geometry}
onBoundaryChange={handleBoundaryChange}
onResultSelect={handleResultSelect}
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Display Name</label>
Display Name
</label>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => 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" 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"
/> />
</div> </div>
@ -554,130 +484,52 @@ function ConfirmStep({
</label> </label>
<input <input
value={slug} value={slug}
onChange={(e) => onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
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" 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"
/> />
</div> </div>
</div> </div>
<div className="w-24"> <div className="grid grid-cols-2 gap-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <div>
Country Code <span className="text-gray-400">(2-letter)</span> <label className="block text-sm font-medium text-gray-700 mb-1">Country</label>
</label> <select
<input value={countryCode}
value={countryCode} onChange={(e) => setCountryCode(e.target.value)}
maxLength={2} 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"
onChange={(e) => 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" <option value=""> no transit </option>
placeholder="DE" {COUNTRIES.map((c) => (
/> <option key={c.code} value={c.code}>{c.label}</option>
))}
</select>
{!countryCode && (
<p className="mt-1 text-xs text-amber-600">No transit scoring without a country.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">OSM Source</label>
<div className={`rounded-md border px-3 py-2 text-xs break-all leading-relaxed ${
geofabrikUrl ? "border-green-200 bg-green-50 text-green-800" : "border-gray-200 bg-gray-50 text-gray-400"
}`}>
{geofabrikUrl
? geofabrikUrl.replace("https://download.geofabrik.de/", "")
: "Select a city above"}
</div>
</div>
</div> </div>
<div className="bg-gray-50 rounded-md p-3"> <div className="flex justify-end pt-1">
<p className="text-xs text-gray-500 font-medium mb-1">Source URL</p> <button
<p className="text-xs text-gray-700 break-all"> onClick={handleSubmit}
{region.properties.urls.pbf} disabled={loading || !slug || !name || !geofabrikUrl}
</p> className="btn-primary"
>
{loading ? "Starting…" : "Start Ingestion"}
</button>
</div> </div>
</div> </div>
<div className="flex gap-3 mt-6">
<button onClick={onBack} className="btn-secondary">
Back
</button>
<button
onClick={async () => {
setLoading(true);
await onConfirm(slug, name, countryCode, boundary);
setLoading(false);
}}
disabled={loading || !slug || !name}
className="btn-primary"
>
{loading ? "Starting…" : "Start Ingestion"}
</button>
</div>
</div>
);
}
// ─── Progress step ────────────────────────────────────────────────────────────
// ─── Main page ────────────────────────────────────────────────────────────────
export default function AddCityPage() {
const [step, setStep] = useState<Step>("browse");
const [selected, setSelected] = useState<GeofabrikFeature | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [ingestError, setIngestError] = useState<string | null>(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 (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Add City</h1>
<p className="text-sm text-gray-500 mb-6">
Select an OpenStreetMap region to import for 15-minute city analysis.
</p>
<StepIndicator current={step} />
{ingestError && (
<div className="mb-4 p-3 bg-red-50 rounded text-sm text-red-700">
{ingestError}
</div>
)}
{step === "browse" && (
<GeofabrikBrowser
onSelect={(f) => {
setSelected(f);
setStep("confirm");
}}
/>
)}
{step === "confirm" && selected && (
<ConfirmStep
region={selected}
onBack={() => setStep("browse")}
onConfirm={handleConfirm}
/>
)}
{step === "ingest" && <CityIngestProgress jobId={jobId} />}
</div> </div>
); );
} }

View file

@ -1,10 +1,9 @@
"use client"; "use client";
import { Suspense, useState } from "react"; import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
function LoginForm() { function LoginForm() {
const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const from = params.get("from") ?? "/admin"; const from = params.get("from") ?? "/admin";
@ -25,7 +24,7 @@ function LoginForm() {
}); });
if (res.ok) { if (res.ok) {
router.push(from); window.location.href = from;
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error ?? "Login failed"); setError(data.error ?? "Login failed");