645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
"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 (
|
||
<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 GeofabrikBrowser({
|
||
onSelect,
|
||
}: {
|
||
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 ───────────────────────────────────
|
||
|
||
interface NominatimResult {
|
||
place_id: number;
|
||
display_name: string;
|
||
lat: string;
|
||
lon: string;
|
||
type: string;
|
||
class: string;
|
||
place_rank: number;
|
||
geojson?: { type: string; coordinates: unknown };
|
||
}
|
||
|
||
function LocationSelector({
|
||
regionGeometry,
|
||
onBoundaryChange,
|
||
}: {
|
||
regionGeometry: GeofabrikFeature["geometry"];
|
||
onBoundaryChange: (boundary: { type: string; coordinates: unknown } | null) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<NominatimResult[]>([]);
|
||
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [searching, setSearching] = useState(false);
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<import("maplibre-gl").Map | null>(null);
|
||
const mapReadyRef = useRef(false);
|
||
|
||
const onBoundaryChangeRef = useRef(onBoundaryChange);
|
||
onBoundaryChangeRef.current = onBoundaryChange;
|
||
|
||
// Notify parent when selection changes.
|
||
useEffect(() => {
|
||
onBoundaryChangeRef.current(selected?.geojson ?? 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&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 (
|
||
<div className="space-y-3">
|
||
{/* Geocoder */}
|
||
<div className="relative">
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="search"
|
||
placeholder="Search for a city or municipality…"
|
||
value={query}
|
||
onChange={(e) => {
|
||
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 && (
|
||
<span className="text-xs text-gray-400 whitespace-nowrap">Searching…</span>
|
||
)}
|
||
</div>
|
||
|
||
{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">
|
||
{results.map((r) => (
|
||
<button
|
||
key={r.place_id}
|
||
type="button"
|
||
onClick={() => {
|
||
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"
|
||
>
|
||
<span className="font-medium text-gray-800">
|
||
{r.display_name.split(",")[0]}
|
||
</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>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mini map */}
|
||
<div
|
||
ref={mapContainerRef}
|
||
className="w-full rounded-md border border-gray-200 overflow-hidden"
|
||
style={{ height: 220 }}
|
||
/>
|
||
|
||
{selected?.geojson && (
|
||
<p className="text-xs text-green-700">
|
||
✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
||
</p>
|
||
)}
|
||
{!selected && (
|
||
<p className="text-xs text-gray-400">
|
||
Search for a city to use its administrative boundary for stats coverage, or leave empty to use the entire dataset.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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<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 [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),
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<div className="card max-w-2xl">
|
||
<h2 className="text-lg font-semibold mb-4">Confirm City Details</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Display Name
|
||
</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Slug <span className="text-gray-400">(URL identifier)</span>
|
||
</label>
|
||
<input
|
||
value={slug}
|
||
onChange={(e) =>
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-24">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Country Code <span className="text-gray-400">(2-letter)</span>
|
||
</label>
|
||
<input
|
||
value={countryCode}
|
||
maxLength={2}
|
||
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"
|
||
placeholder="DE"
|
||
/>
|
||
</div>
|
||
|
||
{/* City boundary selector */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
City Boundary{" "}
|
||
<span className="text-gray-400 font-normal">(optional — for accurate coverage stats)</span>
|
||
</label>
|
||
<p className="text-xs text-gray-500 mb-3">
|
||
Search for the city municipality to use its administrative boundary for
|
||
coverage statistics. The bounding box is still used for OSM import, routing,
|
||
and grid generation.
|
||
</p>
|
||
<LocationSelector
|
||
regionGeometry={region.geometry}
|
||
onBoundaryChange={handleBoundaryChange}
|
||
/>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 rounded-md p-3">
|
||
<p className="text-xs text-gray-500 font-medium mb-1">Source URL</p>
|
||
<p className="text-xs text-gray-700 break-all">
|
||
{region.properties.urls.pbf}
|
||
</p>
|
||
</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>
|
||
);
|
||
}
|