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);
}
};
}

View File

@@ -3,12 +3,13 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Fragment, useState } from 'react';
import { Fragment, useState, useCallback } from 'react';
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
import ProviderTable from '../../../components/table/ProviderTable';
import ProviderMutator from './components/provider/ProviderMutator';
import AreaFilter from './components/areaFilter/AreaFilter';
import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
@@ -44,6 +45,7 @@ export default function JobMutator() {
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -55,9 +57,15 @@ export default function JobMutator() {
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
const navigate = useNavigate();
const actions = useActions();
// Memoize the spatial filter change handler to prevent map reinitializations
const handleSpatialFilterChange = useCallback((data) => {
setSpatialFilter(data);
}, []);
const isSavingEnabled = () => {
return Boolean(notificationAdapterData.length && providerData.length && name);
};
@@ -76,6 +84,7 @@ export default function JobMutator() {
shareWithUsers,
name,
blacklist,
spatialFilter,
enabled,
jobId: jobToBeEdit?.id || null,
});
@@ -206,6 +215,13 @@ export default function JobMutator() {
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Area Filter"
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
>
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconUser}
name="Sharing with user"

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import Map from '../../../../../components/map/Map.jsx';
import './AreaFilter.less';
export default function AreaFilter({ spatialFilter = null, onChange = null }) {
return (
<div className="areaFilter">
<Map
style="STANDARD"
show3dBuildings={false}
enableDrawing={true}
initialSpatialFilter={spatialFilter}
onDrawingChange={onChange}
/>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
.areaFilter {
height: 50rem;
}

View File

@@ -20,54 +20,10 @@ import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
const { Text } = Typography;
const GERMANY_BOUNDS = [
[5.866, 47.27], // Southwest coordinates
[15.042, 55.059], // Northeast coordinates
];
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 &copy; Esri &mdash; 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 MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
@@ -136,117 +92,24 @@ export default function MapView() {
};
}, [navigate]);
// Get map instance reference after MapComponent renders
useEffect(() => {
if (map.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES[style],
center: [10.4515, 51.1657], // Center of Germany
zoom: 4,
maxBounds: GERMANY_BOUNDS,
antialias: true,
});
map.current.addControl(
new maplibregl.NavigationControl({
showCompass: true,
visualizePitch: true,
visualizeRoll: true,
}),
'top-right',
);
map.current.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
}),
);
return () => {
map.current.remove();
};
if (mapContainer.current && !map.current) {
// Wait for MapComponent to initialize the map
const checkMapReady = () => {
if (mapContainer.current?.map) {
map.current = mapContainer.current.map;
} else {
setTimeout(checkMapReady, 100);
}
};
checkMapReady();
}
}, []);
useEffect(() => {
if (map.current) {
map.current.setStyle(STYLES[style]);
}
}, [style]);
useEffect(() => {
if (show3dBuildings && style !== 'STANDARD') {
setStyle('STANDARD');
}
}, [show3dBuildings, style]);
useEffect(() => {
if (!map.current) return;
map.current.setPitch(show3dBuildings ? 45 : 0);
}, [show3dBuildings]);
useEffect(() => {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
});
}
if (!map.current.getLayer('3d-buildings')) {
const layers = map.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;
}
}
map.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 (map.current.getLayer('3d-buildings')) {
map.current.removeLayer('3d-buildings');
}
}
};
add3dLayer();
}, [show3dBuildings, style]);
const handleMapReady = (mapInstance) => {
map.current = mapInstance;
};
const setMapStyle = (value) => {
setStyle(value);
@@ -573,7 +436,7 @@ export default function MapView() {
description="Keep in mind, only listings with proper adresses are being shown on this map."
/>
<div ref={mapContainer} className="map-container" />
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}