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

@@ -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<void>|Promise<Listing[]>} [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;
@@ -90,6 +92,7 @@ class FredyPipelineExecutioner {
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._filterByArea.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.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<Listing[]>} 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.

View File

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

View File

@@ -181,6 +181,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
job.spatialFilter,
prov.id,
job.id,
similarityCache,

View File

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

View File

@@ -0,0 +1,11 @@
/*
* 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;
`);
}

View File

@@ -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",

View File

@@ -14,7 +14,14 @@ describe('#einsAImmobilien testsuite()', () => {
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'einsAImmobilien',
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immobilien.de testsuite()', () => {
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immoscout provider testsuite()', () => {
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immoswp testsuite()', () => {
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immowelt testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -14,7 +14,14 @@ describe('#kleinanzeigen testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'kleinanzeigen',
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#mcMakler testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.mcMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -14,7 +14,14 @@ describe('#neubauKompass testsuite()', () => {
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'neubauKompass',
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#ohneMakler testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.ohneMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -17,6 +17,7 @@ describe('#regionalimmobilien24 testsuite()', () => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'regionalimmobilien24',
similarityCache,

View File

@@ -14,7 +14,7 @@ describe('#sparkasse testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -14,7 +14,7 @@ describe('#wgGesucht testsuite()', () => {
it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,14 @@ describe('#wohnungsboerse testsuite()', () => {
it('should test wohnungsboerse provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'wohnungsboerse',
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

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

104
yarn.lock
View File

@@ -1347,6 +1347,18 @@
resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz"
integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
"@mapbox/geojson-area@^0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10"
integrity sha512-bBqqFn1kIbLBfn7Yq1PzzwVkPYQr9lVUeT8Dhd0NL5n76PBuXzOcuLV7GOSbEB1ia8qWxH4COCvFpziEu/yReA==
dependencies:
wgs84 "0.0.0"
"@mapbox/geojson-normalize@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz#1da1e6b3a7add3ad29909b30f438f60581b7cd80"
integrity sha512-82V7YHcle8lhgIGqEWwtXYN5cy0QM/OHq3ypGhQTbvHR57DF0vMHMjjVSQKFfVXBe/yWCBZTyOuzvK7DFFnx5Q==
"@mapbox/geojson-rewind@^0.5.2":
version "0.5.2"
resolved "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz"
@@ -1360,6 +1372,18 @@
resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz"
integrity sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==
"@mapbox/mapbox-gl-draw@^1.5.1":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.5.1.tgz#0081164556fbe8c444e1297f63c02ecd8e442630"
integrity sha512-DnR/oarZVoIrVHssAn+mtpuGzYH+ebORoPjow46zTBNPod/HQnvIZGtL6hIb5BVWxxH49RC9D20ipxiO9WDRxA==
dependencies:
"@mapbox/geojson-area" "^0.2.2"
"@mapbox/geojson-normalize" "^0.0.1"
"@mapbox/point-geometry" "^1.1.0"
"@turf/projection" "^7.2.0"
fast-deep-equal "^3.1.3"
nanoid "^5.0.9"
"@mapbox/point-geometry@^1.1.0", "@mapbox/point-geometry@~1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz"
@@ -1895,6 +1919,63 @@
resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz"
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
"@turf/boolean-point-in-polygon@^7.0.0":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz#654a940939fecddf1887ca4c95bd5a2f07a42de8"
integrity sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==
dependencies:
"@turf/helpers" "7.3.4"
"@turf/invariant" "7.3.4"
"@types/geojson" "^7946.0.10"
point-in-polygon-hao "^1.1.0"
tslib "^2.8.1"
"@turf/clone@7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-7.3.4.tgz#ae2d9ccd77730181aaa76874308140515e55ddaa"
integrity sha512-pwQ+RyQw986uu7IulY/18NRAebwZZScb084bvVqVkTrllwLSv4oVBqUxmUMiwtp+PNdiRGRFOvNyZqtRsiD+Jw==
dependencies:
"@turf/helpers" "7.3.4"
"@types/geojson" "^7946.0.10"
tslib "^2.8.1"
"@turf/helpers@7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.4.tgz#a8c918981599dddcf452421c7b307c5832d05f02"
integrity sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==
dependencies:
"@types/geojson" "^7946.0.10"
tslib "^2.8.1"
"@turf/invariant@7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.4.tgz#d81f448aa4fdda36047337a688517581e91c12f0"
integrity sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==
dependencies:
"@turf/helpers" "7.3.4"
"@types/geojson" "^7946.0.10"
tslib "^2.8.1"
"@turf/meta@7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.3.4.tgz#8e917d29de9da96a0f95f3f16119ba9abde7dee6"
integrity sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==
dependencies:
"@turf/helpers" "7.3.4"
"@types/geojson" "^7946.0.10"
tslib "^2.8.1"
"@turf/projection@^7.2.0":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@turf/projection/-/projection-7.3.4.tgz#05483cf34e711bf139fc22e236bed2068f1b5461"
integrity sha512-p91zOaLmzoBHzU/2H6Ot1tOhTmAom85n1P7I4Oo0V9xU8hmJXWfNnomLFf/6rnkKDIFZkncLQIBz4iIecZ61sA==
dependencies:
"@turf/clone" "7.3.4"
"@turf/helpers" "7.3.4"
"@turf/meta" "7.3.4"
"@types/geojson" "^7946.0.10"
tslib "^2.8.1"
"@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -1952,7 +2033,7 @@
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/geojson@*", "@types/geojson@^7946.0.16":
"@types/geojson@*", "@types/geojson@^7946.0.10", "@types/geojson@^7946.0.16":
version "7946.0.16"
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz"
integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==
@@ -5561,7 +5642,7 @@ nano-spawn@^2.0.0:
resolved "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz"
integrity sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==
nanoid@5.1.6:
nanoid@5.1.6, nanoid@^5.0.9:
version "5.1.6"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz"
integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==
@@ -5956,6 +6037,13 @@ pify@^4.0.1:
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
point-in-polygon-hao@^1.1.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz#8662abdcc84bcca230cc3ecbb0b0ab1a306f1bd6"
integrity sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==
dependencies:
robust-predicates "^3.0.2"
possible-typed-array-names@^1.0.0:
version "1.1.0"
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
@@ -6701,6 +6789,11 @@ rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
robust-predicates@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rollup@^4.43.0:
version "4.49.0"
resolved "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz"
@@ -7393,7 +7486,7 @@ trouter@^4.0.0:
dependencies:
regexparam "^3.0.0"
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.3.0:
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.3.0, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -7687,6 +7780,11 @@ webdriver-bidi-protocol@0.4.1:
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz#d411e7b8e158408d83bb166b0b4f1054fa3f077e"
integrity sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==
wgs84@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/wgs84/-/wgs84-0.0.0.tgz#34fdc555917b6e57cf2a282ed043710c049cdc76"
integrity sha512-ANHlY4Rb5kHw40D0NJ6moaVfOCMrp9Gpd1R/AIQYg2ko4/jzcJ+TVXYYF6kXJqQwITvEZP4yEthjM7U6rYlljQ==
whatwg-encoding@^3.1.1:
version "3.1.1"
resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz"