diff --git a/apps/web/app/admin/cities/new/page.tsx b/apps/web/app/admin/cities/new/page.tsx index 01985ac..3519c36 100644 --- a/apps/web/app/admin/cities/new/page.tsx +++ b/apps/web/app/admin/cities/new/page.tsx @@ -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(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(() => { - 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 (
@@ -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({
)}
+
- {selected?.geojson ? ( + + {subPolygons.length > 1 ? ( +

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

+ ) : selected?.geojson ? (

- ✓ Boundary: {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type}) + ✓ {selected.display_name.split(",").slice(0, 2).join(", ")} ({selected.geojson.type})

) : (

@@ -295,7 +384,6 @@ export default function AddCityPage() { const [loading, setLoading] = useState(false); const geofabrikIndexRef = useRef(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 (

Add City

-

- Search for a city to configure and start ingestion. -

+

Search for a city to configure and start ingestion.

- {error && ( -
{error}
- )} + {error &&
{error}
}
- {/* Geocoder + map */}
- +
- {/* Name + Slug */}
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" />
@@ -405,13 +485,12 @@ export default function AddCityPage() { 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" />
- {/* Country + derived OSM source */}
@@ -431,15 +510,13 @@ export default function AddCityPage() {
- {geofabrikUrl ? ( -
- {geofabrikUrl.replace("https://download.geofabrik.de/", "")} -
- ) : ( -
- {matchedRegion === null && name ? "No Geofabrik region found" : "Select a city above"} -
- )} +
+ {geofabrikUrl + ? geofabrikUrl.replace("https://download.geofabrik.de/", "") + : "Select a city above"} +