feat(): map area filter (#273)

* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers
This commit is contained in:
Stephan
2026-03-08 09:44:18 +01:00
committed by GitHub
parent 0cad05124a
commit 0bcfa1d4ad
27 changed files with 715 additions and 176 deletions

View File

@@ -0,0 +1,213 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
import './Map.less';
export const GERMANY_BOUNDS = [
[5.866, 47.27], // Southwest coordinates
[15.042, 55.059], // Northeast coordinates
];
export const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
SATELLITE: {
version: 8,
sources: {
'satellite-tiles': {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution:
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
},
'satellite-labels': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
attribution: '© Esri',
},
},
layers: [
{
id: 'satellite-tiles',
type: 'raster',
source: 'satellite-tiles',
minzoom: 0,
maxzoom: 19,
},
{
id: 'satellite-labels',
type: 'raster',
source: 'satellite-labels',
minzoom: 0,
maxzoom: 19,
},
],
},
};
export default function Map({
style = 'STANDARD',
show3dBuildings = false,
onMapReady = null,
enableDrawing = false,
initialSpatialFilter = null,
onDrawingChange = null,
}) {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const drawRef = useRef(null);
// Initialize map - ONLY when container changes, never reinitialize
useEffect(() => {
if (mapRef.current) return; // Map already exists, don't reinitialize
mapRef.current = new maplibregl.Map({
container: mapContainerRef.current,
style: STYLES[style],
center: [10.4515, 51.1657], // Center of Germany
zoom: 4,
maxBounds: GERMANY_BOUNDS,
antialias: true,
});
mapRef.current.addControl(
new maplibregl.NavigationControl({
showCompass: true,
visualizePitch: true,
visualizeRoll: true,
}),
'top-right',
);
mapRef.current.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
}),
);
// Initialize drawing extension only if enabled
if (enableDrawing) {
fixMapboxDrawCompatibility();
drawRef.current = addDrawingControl(mapRef.current);
}
// Call onMapReady callback if provided
if (onMapReady) {
onMapReady(mapRef.current);
}
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [mapContainerRef]); // ONLY depend on mapContainerRef - nothing else!
// Load spatial filter and setup area filter event listeners
useEffect(() => {
if (!mapRef.current || !drawRef.current || !enableDrawing) return;
// Load initial spatial filter if provided
if (initialSpatialFilter) {
try {
drawRef.current.set(initialSpatialFilter);
} catch (error) {
console.error('Error loading spatial filter:', error);
}
}
// Setup drawing event listeners
const cleanup = setupAreaFilterEventListeners(mapRef.current, drawRef.current, onDrawingChange);
return cleanup;
}, [initialSpatialFilter, onDrawingChange, enableDrawing]);
// Handle style changes
useEffect(() => {
if (mapRef.current) {
mapRef.current.setStyle(STYLES[style]);
}
}, [style]);
// Handle 3D buildings layer
useEffect(() => {
if (!mapRef.current) return;
const add3dLayer = () => {
if (!mapRef.current || !mapRef.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!mapRef.current.getSource('openfreemap')) {
mapRef.current.addSource('openfreemap', {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
});
}
if (!mapRef.current.getLayer('3d-buildings')) {
const layers = mapRef.current.getStyle().layers;
let labelLayerId;
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
labelLayerId = layers[i].id;
break;
}
}
mapRef.current.addLayer(
{
id: '3d-buildings',
source: 'openfreemap',
'source-layer': 'building',
type: 'fill-extrusion',
minzoom: 15,
filter: ['!=', ['get', 'hide_3d'], true],
paint: {
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'render_height'],
0,
'lightgray',
200,
'royalblue',
400,
'lightblue',
],
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
'fill-extrusion-opacity': 0.6,
},
},
labelLayerId,
);
}
} else {
if (mapRef.current.getLayer('3d-buildings')) {
mapRef.current.removeLayer('3d-buildings');
}
}
};
add3dLayer();
}, [show3dBuildings, style]);
// Handle pitch for 3D
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.setPitch(show3dBuildings ? 45 : 0);
}, [show3dBuildings]);
return <div ref={mapContainerRef} className="map-container" />;
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
.map-container {
height: 100%;
}
/* Fix Mapbox Draw cursors for MapLibre GL compatibility */
.maplibregl-map.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: pointer;
}
.maplibregl-map.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mouse-add .maplibregl-canvas-container.maplibregl-interactive {
cursor: crosshair;
}
.maplibregl-map.mouse-move.mode-direct_select .maplibregl-canvas-container.maplibregl-interactive {
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
.maplibregl-map.mode-direct_select.feature-vertex.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mode-direct_select.feature-midpoint.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: cell;
}
.maplibregl-map.mode-direct_select.feature-feature.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mode-static.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import MapboxDraw from '@mapbox/mapbox-gl-draw';
const drawStyles = [
{
id: 'gl-draw-polygon-fill-inactive',
type: 'fill',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
paint: { 'fill-color': '#3bb2d0', 'fill-outline-color': '#3bb2d0', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-fill-active',
type: 'fill',
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
paint: { 'fill-color': '#fbb03b', 'fill-outline-color': '#fbb03b', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-midpoint',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-polygon-stroke-inactive',
type: 'line',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
},
{
id: 'gl-draw-polygon-stroke-active',
type: 'line',
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
},
{
id: 'gl-draw-line-inactive',
type: 'line',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
},
{
id: 'gl-draw-line-active',
type: 'line',
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
},
{
id: 'gl-draw-polygon-and-line-vertex-stroke-inactive',
type: 'circle',
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
paint: { 'circle-radius': 5, 'circle-color': '#fff' },
},
{
id: 'gl-draw-polygon-and-line-vertex-inactive',
type: 'circle',
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-point-point-stroke-inactive',
type: 'circle',
filter: [
'all',
['==', 'active', 'false'],
['==', '$type', 'Point'],
['==', 'meta', 'feature'],
['!=', 'mode', 'static'],
],
paint: { 'circle-radius': 5, 'circle-opacity': 1, 'circle-color': '#fff' },
},
{
id: 'gl-draw-point-inactive',
type: 'circle',
filter: [
'all',
['==', 'active', 'false'],
['==', '$type', 'Point'],
['==', 'meta', 'feature'],
['!=', 'mode', 'static'],
],
paint: { 'circle-radius': 3, 'circle-color': '#3bb2d0' },
},
{
id: 'gl-draw-point-stroke-active',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
paint: { 'circle-radius': 7, 'circle-color': '#fff' },
},
{
id: 'gl-draw-point-active',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
paint: { 'circle-radius': 5, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-polygon-fill-static',
type: 'fill',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
paint: { 'fill-color': '#404040', 'fill-outline-color': '#404040', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-stroke-static',
type: 'line',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#404040', 'line-width': 2 },
},
{
id: 'gl-draw-line-static',
type: 'line',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#404040', 'line-width': 2 },
},
{
id: 'gl-draw-point-static',
type: 'circle',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']],
paint: { 'circle-radius': 5, 'circle-color': '#404040' },
},
];
export function fixMapboxDrawCompatibility() {
MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
}
export function addDrawingControl(map) {
const draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
trash: true,
},
styles: drawStyles,
});
map.addControl(draw, 'top-left');
return draw;
}
export function setupAreaFilterEventListeners(map, draw, onDrawingChange) {
if (!map || !draw) return () => {};
const handleDrawChange = () => {
if (draw) {
const data = draw.getAll();
if (onDrawingChange) {
onDrawingChange(data);
}
}
};
map.on('draw.create', handleDrawChange);
map.on('draw.update', handleDrawChange);
map.on('draw.delete', handleDrawChange);
// Return cleanup function
return () => {
if (map) {
map.off('draw.create', handleDrawChange);
map.off('draw.update', handleDrawChange);
map.off('draw.delete', handleDrawChange);
}
};
}