mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
213
ui/src/components/map/Map.jsx
Normal file
213
ui/src/components/map/Map.jsx
Normal 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" />;
|
||||
}
|
||||
45
ui/src/components/map/Map.less
Normal file
45
ui/src/components/map/Map.less
Normal 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;
|
||||
}
|
||||
177
ui/src/components/map/MapDrawingExtension.js
Normal file
177
ui/src/components/map/MapDrawingExtension.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user