458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
"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<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;
|
|
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 (
|
|
<div className="space-y-3">
|
|
<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([]); 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 && <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>
|
|
<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>
|
|
) : (
|
|
<p className="text-xs text-gray-400">
|
|
Search for a city to set its administrative boundary and auto-fill the fields below.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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<GeofabrikFeature | null>(null);
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const geofabrikIndexRef = useRef<GeofabrikIndex | null>(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 (
|
|
<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">
|
|
{/* Geocoder + map */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
City Boundary{" "}
|
|
<span className="text-gray-400 font-normal">(optional — auto-fills fields below)</span>
|
|
</label>
|
|
<LocationSelector
|
|
onBoundaryChange={handleBoundaryChange}
|
|
onResultSelect={handleResultSelect}
|
|
/>
|
|
</div>
|
|
|
|
{/* Name + Slug */}
|
|
<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)}
|
|
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"
|
|
/>
|
|
</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, "-"))}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Country + derived OSM source */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Country</label>
|
|
<select
|
|
value={countryCode}
|
|
onChange={(e) => setCountryCode(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"
|
|
>
|
|
<option value="">— no transit —</option>
|
|
{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>
|
|
{geofabrikUrl ? (
|
|
<div className="rounded-md border border-green-200 bg-green-50 px-3 py-2 text-xs text-green-800 break-all leading-relaxed">
|
|
{geofabrikUrl.replace("https://download.geofabrik.de/", "")}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-400">
|
|
{matchedRegion === null && name ? "No Geofabrik region found" : "Select a city above"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-1">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={loading || !slug || !name || !geofabrikUrl}
|
|
className="btn-primary"
|
|
>
|
|
{loading ? "Starting…" : "Start Ingestion"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|