diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index 85878e9..9be233e 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -14,6 +14,7 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js'; import { distanceMeters } from './services/listings/distanceCalculator.js'; import { getUserSettings } from './services/storage/settingsStorage.js'; import { updateListingDistance } from './services/storage/listingsStorage.js'; +import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; /** * @typedef {Object} Listing @@ -58,16 +59,17 @@ class FredyPipelineExecutioner { * @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape. * @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings. * @param {(url:string, waitForSelector?:string)=>Promise|Promise} [providerConfig.getListings] Optional override to fetch listings. - * * @param {Object} notificationConfig Notification configuration passed to notification adapters. + * @param {Object} spatialFilter Optional spatial filter configuration. * @param {string} providerId The ID of the provider currently in use. * @param {string} jobKey Key of the job that is currently running (from within the config). * @param {SimilarityCache} similarityCache Cache instance for checking similar entries. * @param browser */ - constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache, browser) { + constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) { this._providerConfig = providerConfig; this._notificationConfig = notificationConfig; + this._spatialFilter = spatialFilter; this._providerId = providerId; this._jobKey = jobKey; this._similarityCache = similarityCache; @@ -87,6 +89,7 @@ class FredyPipelineExecutioner { .then(this._filter.bind(this)) .then(this._findNew.bind(this)) .then(this._geocode.bind(this)) + .then(this._filterByArea.bind(this)) .then(this._save.bind(this)) .then(this._calculateDistance.bind(this)) .then(this._filterBySimilarListings.bind(this)) @@ -113,6 +116,38 @@ class FredyPipelineExecutioner { return newListings; } + /** + * Filter listings by area using the provider's area filter if available. + * Only filters if areaFilter is set on the provider AND the listing has coordinates. + * + * @param {Listing[]} newListings New listings to filter by area. + * @returns {Promise} Resolves with listings that are within the area (or not filtered if no area is set). + */ + _filterByArea(newListings) { + const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon'); + + // If no area filter is set, return all listings + if (!polygonFeatures?.length) { + return newListings; + } + + // Filter listings by area - keep only those within the polygon + const filteredListings = newListings.filter((listing) => { + // If listing doesn't have coordinates, keep it (don't filter out) + if (listing.latitude == null || listing.longitude == null) { + return true; + } + + // Check if the point is inside the polygons + const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat] + const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature)); + + return isInPolygon; + }); + + return filteredListings; + } + /** * Fetch listings from the provider, using the default Extractor flow unless * a provider-specific getListings override is supplied. diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 9113078..1798cf7 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -163,7 +163,16 @@ jobRouter.post('/:jobId/run', async (req, res) => { }); jobRouter.post('/', async (req, res) => { - const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body; + const { + provider, + notificationAdapter, + name, + blacklist = [], + jobId, + enabled, + shareWithUsers = [], + spatialFilter = null, + } = req.body; const settings = await getSettings(); try { let jobFromDb = jobStorage.getJob(jobId); @@ -187,6 +196,7 @@ jobRouter.post('/', async (req, res) => { provider, notificationAdapter, shareWithUsers, + spatialFilter, }); } catch (error) { res.send(new Error(error)); diff --git a/lib/services/jobs/jobExecutionService.js b/lib/services/jobs/jobExecutionService.js index b52702d..471279c 100644 --- a/lib/services/jobs/jobExecutionService.js +++ b/lib/services/jobs/jobExecutionService.js @@ -181,6 +181,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) { await new FredyPipelineExecutioner( matchedProvider.config, job.notificationAdapter, + job.spatialFilter, prov.id, job.id, similarityCache, diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index e4fe253..bf88003 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -30,6 +30,7 @@ export const upsertJob = ({ notificationAdapter, userId, shareWithUsers = [], + spatialFilter = null, }) => { const id = jobId || nanoid(); const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0]; @@ -42,7 +43,8 @@ export const upsertJob = ({ blacklist = @blacklist, provider = @provider, notification_adapter = @notification_adapter, - shared_with_user = @shareWithUsers + shared_with_user = @shareWithUsers, + spatial_filter = @spatialFilter WHERE id = @id`, { id, @@ -52,12 +54,13 @@ export const upsertJob = ({ shareWithUsers: toJson(shareWithUsers ?? []), provider: toJson(provider ?? []), notification_adapter: toJson(notificationAdapter ?? []), + spatialFilter: spatialFilter ? toJson(spatialFilter) : null, }, ); } else { SqliteConnection.execute( - `INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user) - VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`, + `INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter) + VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`, { id, user_id: ownerId, @@ -67,6 +70,7 @@ export const upsertJob = ({ provider: toJson(provider ?? []), shareWithUsers: toJson(shareWithUsers ?? []), notification_adapter: toJson(notificationAdapter ?? []), + spatialFilter: spatialFilter ? toJson(spatialFilter) : null, }, ); } @@ -87,6 +91,7 @@ export const getJob = (jobId) => { j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, + j.spatial_filter AS spatialFilter, (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings FROM jobs j WHERE j.id = @id @@ -101,6 +106,7 @@ export const getJob = (jobId) => { provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: fromJson(row.notificationAdapter, []), + spatialFilter: fromJson(row.spatialFilter, null), }; }; @@ -150,6 +156,7 @@ export const getJobs = () => { j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, + j.spatial_filter AS spatialFilter, (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings FROM jobs j WHERE j.enabled = 1 @@ -162,6 +169,7 @@ export const getJobs = () => { provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: fromJson(row.notificationAdapter, []), + spatialFilter: fromJson(row.spatialFilter, null), })); }; @@ -251,6 +259,7 @@ export const queryJobs = ({ j.provider, j.shared_with_user, j.notification_adapter AS notificationAdapter, + j.spatial_filter AS spatialFilter, (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings FROM jobs j ${whereSql} @@ -266,6 +275,7 @@ export const queryJobs = ({ provider: fromJson(row.provider, []), shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: fromJson(row.notificationAdapter, []), + spatialFilter: fromJson(row.spatialFilter, null), })); return { totalNumber, page: safePage, result }; diff --git a/lib/services/storage/migrations/sql/11.add-spatial-filter.js b/lib/services/storage/migrations/sql/11.add-spatial-filter.js new file mode 100644 index 0000000..b14301d --- /dev/null +++ b/lib/services/storage/migrations/sql/11.add-spatial-filter.js @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters +export function up(db) { + db.exec(` + ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL; + `); +} + +export function down(db) { + db.exec(` + ALTER TABLE jobs DROP COLUMN spatial_filter; + `); +} diff --git a/package.json b/package.json index 5f2e37f..2c635a3 100755 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@douyinfe/semi-icons": "^2.91.0", "@douyinfe/semi-ui": "2.91.0", "@douyinfe/semi-ui-19": "^2.91.0", + "@mapbox/mapbox-gl-draw": "^1.5.1", "@sendgrid/mail": "8.1.6", "@vitejs/plugin-react": "5.1.4", "adm-zip": "^0.5.16", @@ -70,6 +71,7 @@ "body-parser": "2.2.2", "chart.js": "^4.5.1", "cheerio": "^1.2.0", + "@turf/boolean-point-in-polygon": "^7.0.0", "cookie-session": "2.1.1", "handlebars": "4.7.8", "lodash": "4.17.23", diff --git a/ui/src/components/map/Map.jsx b/ui/src/components/map/Map.jsx new file mode 100644 index 0000000..9a6cd8a --- /dev/null +++ b/ui/src/components/map/Map.jsx @@ -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
; +} diff --git a/ui/src/components/map/Map.less b/ui/src/components/map/Map.less new file mode 100644 index 0000000..b2f7a49 --- /dev/null +++ b/ui/src/components/map/Map.less @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/* 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; +} diff --git a/ui/src/components/map/MapDrawingExtension.js b/ui/src/components/map/MapDrawingExtension.js new file mode 100644 index 0000000..3643092 --- /dev/null +++ b/ui/src/components/map/MapDrawingExtension.js @@ -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); + } + }; +} diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx index 002d249..ff3cf55 100644 --- a/ui/src/views/jobs/mutation/JobMutation.jsx +++ b/ui/src/views/jobs/mutation/JobMutation.jsx @@ -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() { /> + + + + + +
+ ); +} diff --git a/ui/src/views/jobs/mutation/components/areaFilter/AreaFilter.less b/ui/src/views/jobs/mutation/components/areaFilter/AreaFilter.less new file mode 100644 index 0000000..a7ae74c --- /dev/null +++ b/ui/src/views/jobs/mutation/components/areaFilter/AreaFilter.less @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +.areaFilter-container { + height: 500px; + + .map-container { + height: 100%; + } +} + diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index 40f3238..0bd16e3 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -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 © 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 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(); - }; - }, []); - - 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'); + 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(); + } + }, []); - 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." /> -
+