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

@@ -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}