feat: allow subset selection of multipolygons
This commit is contained in:
parent
090afdaa44
commit
e60debd10c
1 changed files with 187 additions and 110 deletions
|
|
@ -6,31 +6,23 @@ 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;
|
||||
}
|
||||
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[][]),
|
||||
);
|
||||
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[][] =
|
||||
|
|
@ -42,7 +34,6 @@ function bboxArea(geometry: GeofabrikFeature["geometry"]): number {
|
|||
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(
|
||||
|
|
@ -73,6 +64,19 @@ 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" },
|
||||
|
|
@ -102,36 +106,103 @@ function LocationSelector({
|
|||
const [selected, setSelected] = useState<NominatimResult | null>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [subPolygons, setSubPolygons] = useState<SubPolygon[]>([]);
|
||||
const subPolygonsRef = useRef<SubPolygon[]>([]);
|
||||
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;
|
||||
|
||||
// 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(() => {
|
||||
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 {
|
||||
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 () => {
|
||||
|
|
@ -155,50 +226,7 @@ function LocationSelector({
|
|||
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]);
|
||||
|
||||
// Initialize mini map.
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -207,6 +235,7 @@ function LocationSelector({
|
|||
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",
|
||||
|
|
@ -214,16 +243,57 @@ function LocationSelector({
|
|||
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
|
||||
// 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] } });
|
||||
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 () => {
|
||||
|
|
@ -234,6 +304,8 @@ function LocationSelector({
|
|||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const selectedCount = subPolygons.filter((sp) => sp.selected).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
|
|
@ -244,7 +316,7 @@ function LocationSelector({
|
|||
value={query}
|
||||
onChange={(e) => {
|
||||
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)}
|
||||
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"
|
||||
|
|
@ -268,10 +340,27 @@ function LocationSelector({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-gray-500">
|
||||
{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>
|
||||
) : selected?.geojson ? (
|
||||
<p className="text-xs text-green-700">
|
||||
✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
||||
✓ {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
|
|
@ -295,7 +384,6 @@ export default function AddCityPage() {
|
|||
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())
|
||||
|
|
@ -316,10 +404,11 @@ export default function AddCityPage() {
|
|||
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);
|
||||
setMatchedRegion(
|
||||
geofabrikIndexRef.current
|
||||
? findBestRegion(result.lat, result.lon, geofabrikIndexRef.current)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -366,35 +455,26 @@ export default function AddCityPage() {
|
|||
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>
|
||||
<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>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -405,13 +485,12 @@ export default function AddCityPage() {
|
|||
<input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
|
||||
placeholder="amsterdam"
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
|
@ -431,15 +510,13 @@ export default function AddCityPage() {
|
|||
</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 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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue