From 8b767c8200edd903a0241a97b4c21769b5018b4a Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 09:20:23 +0200 Subject: [PATCH 01/10] Repo url changed --- SETTINGS.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SETTINGS.ts b/SETTINGS.ts index cd7e8a23..eefa73b7 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -3,7 +3,8 @@ */ // Repository -export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; +//export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; +export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; // Rules export const MAX_CLUSTER_SIZE: number = 1250; @@ -16,4 +17,4 @@ export const SOUND_GUIDE_URL = 'https://docs.google.com/document/d/1aDBv3UWOxngd export const POWER_GRID_GEOJSON_URL = 'https://bl.skookum.cc/api/bl26/v/default/power_grid'; export const HAS_SEEN_PLACEMENT_WELCOME_COOKIE_KEY = 'hasSeenPlacementWelcome'; -export const HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY = 'hasSeenEditorInstructions'; \ No newline at end of file +export const HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY = 'hasSeenEditorInstructions'; From 625921bfac1b9587dabc30d4abb5074e04f4845f Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 09:42:34 +0200 Subject: [PATCH 02/10] Added snapping and snapping options to editor mode --- SETTINGS.ts | 1 + src/editor/editor.ts | 222 ++++++++++++--------- src/entities/entity.ts | 33 +++- src/loaders/loadBaseLayers.ts | 352 ++++++++++++++++++---------------- src/utils/snapDistance.ts | 13 ++ 5 files changed, 357 insertions(+), 264 deletions(-) create mode 100644 src/utils/snapDistance.ts diff --git a/SETTINGS.ts b/SETTINGS.ts index eefa73b7..c70e999c 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -11,6 +11,7 @@ export const MAX_CLUSTER_SIZE: number = 1250; export const MAX_POWER_NEED: number = 8000; export const MAX_POINTS_BEFORE_WARNING: number = 10; export const FIRE_BUFFER_IN_METER: number = 5; +export const SNAP_DISTANCE_METERS: number = 2; export const TOTAL_MEMBERSHIPS_SOLD = 5432; // 2026, this is used for the stats page. export const SOUND_GUIDE_URL = 'https://docs.google.com/document/d/1aDBv3UWOxngdjWd_z4N34Wcm7r7GvD-gINGwQIr4ti8'; diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 2e8f6704..6d9596d8 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -12,7 +12,8 @@ import 'leaflet-search'; import { EditorPopup } from './editorPopup'; import { AdminAPI } from './adminAPI'; import { HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY } from '../../SETTINGS'; -import { setCookie, getCookie } from "../utils/cookie"; +import { setCookie, getCookie } from '../utils/cookie'; +import { metersToSnapPixels } from '../utils/snapDistance'; /** * The Editor class keeps track of the user status regarding editing and @@ -29,7 +30,7 @@ export class Editor { private _popup: L.Popup; private _editorPopup: EditorPopup; /** If the editor should be active or not */ - private _isEditMode: boolean = false; + private _isEditMode: boolean = true; // This will skip checking entity rules, hide controls and hide messages. private _isCleanAndQuietMode: boolean; @@ -71,13 +72,7 @@ export class Editor { // When blur is sent as parameter, the next mode is dynamicly determined if (nextMode == 'blur') { - if ( - ( - prevMode == 'editing-shape' - || prevMode == 'moving-shape' - ) - && prevEntity - ) { + if ((prevMode == 'editing-shape' || prevMode == 'moving-shape') && prevEntity) { nextMode = 'selected'; nextEntity = nextEntity || prevEntity; //re-center the pop up on the new layer, in case the layer has moved @@ -102,6 +97,7 @@ export class Editor { // Deselect and stop editing if (this._mode == 'none') { + this._syncPlacementSnapTargets(null); this.setSelected(null, prevEntity); this.setPopup('none'); return; @@ -109,6 +105,7 @@ export class Editor { // Select an entity for editing if (this._mode == 'selected' && nextEntity) { + this._syncPlacementSnapTargets(null); this.setSelected(nextEntity, prevEntity); this.setPopup('info', nextEntity); @@ -120,19 +117,21 @@ export class Editor { return; } - // Edit the shape of the entity + // Edit the shape of the entity β€” snap vertices to other camp areas only if (this._mode == 'editing-shape' && nextEntity) { + this._syncPlacementSnapTargets(nextEntity); nextEntity.layer.pm.enable({ editMode: true, - snappable: false, allowSelfIntersection: false, + ...this._getShapeEditSnapOptions(), }); this.setSelected(nextEntity); this.setPopup('none'); return; } - // Move the shape of the entity + // Move the shape of the entity β€” whole-shape drag, no vertex snapping if (this._mode == 'moving-shape' && nextEntity) { + this._syncPlacementSnapTargets(null); this.setSelected(nextEntity); this.setPopup('none'); this.UpdateOnScreenDisplay(nextEntity, 'Drag to move'); @@ -183,13 +182,13 @@ export class Editor { // Move this logic to the entityInfoEditor? let editEntityCallback = async (action: string, extraInfo?: string) => { switch (action) { - case "delete": + case 'delete': this.deleteAndRemoveEntity(this._selected, extraInfo); - Messages.showNotification("Deleted", 'success'); + Messages.showNotification('Deleted', 'success'); this._popup.close(); break; - case "save": + case 'save': if (!this._selected.hasChanges()) { break; } @@ -200,7 +199,7 @@ export class Editor { this.setPopup('info', entityInResponse); break; - case "restore-shape": + case 'restore-shape': // First remove currently drawn shape to avoid duplicates this.removeEntityNameTooltip(this._selected); this.removeEntityFromLayers(this._selected); @@ -239,20 +238,20 @@ export class Editor { this._compareRevDiffLayer, editEntityCallback.bind(this), ); - var fullScreenPopup = document.getElementById("fullScreenPopup"); - fullScreenPopup.innerHTML = ""; // Need to remove the old info + var fullScreenPopup = document.getElementById('fullScreenPopup'); + fullScreenPopup.innerHTML = ''; // Need to remove the old info fullScreenPopup.appendChild(contentFullScreenPopup); fullScreenPopup.classList.remove('hidden'); // Add a close button to the fullScreenPopup - let span = document.createElement("span"); - let closeButton = document.createElement("sl-icon"); - closeButton.style.margin = "5px 5px 5px 10px"; - closeButton.style.fontSize = "20px"; - closeButton.setAttribute("name", "x-lg"); // sets the icon + let span = document.createElement('span'); + let closeButton = document.createElement('sl-icon'); + closeButton.style.margin = '5px 5px 5px 10px'; + closeButton.style.fontSize = '20px'; + closeButton.setAttribute('name', 'x-lg'); // sets the icon closeButton.onclick = () => { - this.setMode("none"); + this.setMode('none'); }; - let header = fullScreenPopup.querySelector("header"); + let header = fullScreenPopup.querySelector('header'); span.appendChild(closeButton); header.appendChild(span); @@ -286,8 +285,7 @@ export class Editor { } let latestEntity = entity.revisions[latestKey]; if (entity.revision != latestEntity.revision) { - let diffDescription: string = - `Someone else edited this shape at the same time as you
+ let diffDescription: string = `Someone else edited this shape at the same time as you
You have now overwritten their changes, see the differences in the history tab.`; Messages.showNotification(diffDescription, 'danger', undefined, 3600000); } @@ -398,9 +396,11 @@ export class Editor { // Update the buffered layer when the layer has a vertex removed entity.layer.on('pm:vertexremoved', (e) => { + entity.pruneToSinglePolygonLayer(e.layer); + if (e.layer._rings.length == 0) { this.deleteAndRemoveEntity(this._selected, 'No vertex remaining, automatic deletion of entity'); - return + return; } entity.updateBufferedLayer(); @@ -503,7 +503,7 @@ export class Editor { let hideWarnings = this._hideWarningColors || this._isCleanAndQuietMode; for (const entity of batch) { entity.checkAllRules(); - entity.setLayerStyle("severity", hideWarnings); + entity.setLayerStyle('severity', hideWarnings); } requestAnimationFrame(this.checkRulesSlowly.bind(this)); @@ -604,28 +604,11 @@ export class Editor { L.PM.setOptIn(true); // Add controls for creating and editing shapes to the map - this._map.pm.addControls({ - position: 'bottomleft', - drawPolygon: false, - drawCircle: false, - drawMarker: false, - drawPolyline: false, - drawRectangle: false, - drawCircleMarker: false, - drawText: false, - removalMode: false, - editControls: false, - snappable: false, - }); + this._map.pm.addControls(this._getPmToolbarOptions(false)); + this._updateSnapOptions(); // Set path style options for newly created layers this._map.pm.setPathOptions(LayerStyles.Default); - this._map.pm.setGlobalOptions({ - tooltips: true, - allowSelfIntersection: false, - snappable: true, - draggable: true, - }); this.setupMapEvents(this._map); @@ -648,13 +631,65 @@ export class Editor { // Add search control if (!this._isCleanAndQuietMode) { //@ts-ignore - map.addControl(new L.Control.Search({ - layer: this._placementLayers, - propertyName: 'name', - marker: false, - zoom: 19, - initial: false, - })); + map.addControl( + new L.Control.Search({ + layer: this._placementLayers, + propertyName: 'name', + marker: false, + zoom: 19, + initial: false, + }), + ); + } + } + + private _getPmToolbarOptions(drawPolygon: boolean) { + return { + position: 'bottomleft' as const, + drawPolygon, + drawCircle: false, + drawMarker: false, + drawPolyline: false, + drawRectangle: false, + drawCircleMarker: false, + drawText: false, + removalMode: false, + editControls: false, + snappingOption: false, + }; + } + + /** Snap a dragged/placed vertex to corners and edges of other camp areas. */ + private _getShapeEditSnapOptions() { + return { + snappable: true, + snapVertex: true, + snapSegment: true, + snapMiddle: false, + snapDistance: metersToSnapPixels(this._map), + }; + } + + private _updateSnapOptions() { + this._map.pm.setGlobalOptions({ + tooltips: true, + allowSelfIntersection: false, + draggable: true, + layerGroup: this._placementLayers, + ...this._getShapeEditSnapOptions(), + }); + } + + /** Other camp areas are snap targets; optionally exclude the area being vertex-edited. */ + private _syncPlacementSnapTargets(excludedEntity: MapEntity | null = null) { + for (const entityId in this._currentRevisions) { + const entity = this._currentRevisions[entityId]; + const snapIgnore = entity === excludedEntity; + if (entity.layer.options.snapIgnore === snapIgnore) { + continue; + } + entity.layer.options.snapIgnore = snapIgnore; + L.PM.reInitLayer(entity.layer); } } @@ -662,34 +697,35 @@ export class Editor { this._hideWarningColors = hide; for (const entityid in this._currentRevisions) { let entity = this._currentRevisions[entityid]; - entity.setLayerStyle("severity", this._hideWarningColors); + entity.setLayerStyle('severity', this._hideWarningColors); } } private async addToggleEditButton() { // Edit button might be still shown in users browser because of cache, so lets check if editing actually is possible. if (await AdminAPI.isEditAllowed()) { - this._map.addControl(ButtonsFactory.edit(this._isEditMode, async () => { - // This callback should return true if edit mode should be toggled on, false if not. - if (!this._isEditMode) { - const isSecretSet = await AdminAPI.isEditButtonSecretSet(); - - if (isSecretSet) { - const pw = prompt('Password? 🀐'); - if (pw == null || pw.trim() === '') - return false; - - const success = await AdminAPI.CheckIfSecretIsSet(pw); - if (!success) { - alert('Wrong password! 😒'); - return false; + this._map.addControl( + ButtonsFactory.edit(this._isEditMode, async () => { + // This callback should return true if edit mode should be toggled on, false if not. + if (!this._isEditMode) { + const isSecretSet = await AdminAPI.isEditButtonSecretSet(); + + if (isSecretSet) { + const pw = prompt('Password? 🀐'); + if (pw == null || pw.trim() === '') return false; + + const success = await AdminAPI.CheckIfSecretIsSet(pw); + if (!success) { + alert('Wrong password! 😒'); + return false; + } } } - } - this.toggleEditMode(); - return true; - })); + this.toggleEditMode(); + return true; + }), + ); // Auto click the button to enable edit mode setTimeout(() => { //document.querySelector('.btn.button-shake-animate.leaflet-control').click(); @@ -706,7 +742,7 @@ export class Editor { public async toggleEditMode() { // Doublecheck if editing still is allowed. - if (!await AdminAPI.isEditAllowed()) { + if (!(await AdminAPI.isEditAllowed())) { this._isEditMode = false; return; // Perhaps remove the button or show a message? @@ -744,9 +780,8 @@ export class Editor { this.setMode('none'); } - this._map.pm.addControls({ - drawPolygon: this._isEditMode, - }); + this._map.pm.addControls(this._getPmToolbarOptions(this._isEditMode)); + this._updateSnapOptions(); //Use changeActionsOfControl to only show the cancel button on the draw polygon toolbar this._map.pm.Toolbar.changeActionsOfControl('Polygon', ['cancel', 'removeLastVertex']); @@ -758,9 +793,9 @@ export class Editor { }; // This function is called when the user starts drawing a new polygon, it adds the distance to the tooltip - this._map.on("pm:drawstart", ({ workingLayer }) => { + this._map.on('pm:drawstart', ({ workingLayer }) => { // calculate the distance between the latest and previous vertices - workingLayer.on("pm:vertexadded", (e) => { + workingLayer.on('pm:vertexadded', (e) => { let coords = e.workingLayer._latlngs; if (coords.length < 2) { return; @@ -780,12 +815,12 @@ export class Editor { writeDistanceOnTooltip(distance); }; - workingLayer.on("pm:snapdrag", snapdragFn); + workingLayer.on('pm:snapdrag', snapdragFn); // Used when the vertex gets snapped to another vertex, otherwise the distance is calculated from the mouse position - workingLayer.on("pm:snap", (e) => { + workingLayer.on('pm:snap', (e) => { // toggle off the snapdrag event - workingLayer.off("pm:snapdrag", snapdragFn); + workingLayer.off('pm:snapdrag', snapdragFn); let coords = e.workingLayer._latlngs; let prev = coords[coords.length - 1]; let next = e.snapLatLng; @@ -794,8 +829,8 @@ export class Editor { }); // toggle on the snapdrag event again - workingLayer.on("pm:unsnap", (e) => { - workingLayer.on("pm:snapdrag", snapdragFn); + workingLayer.on('pm:unsnap', (e) => { + workingLayer.on('pm:snapdrag', snapdragFn); }); }); } @@ -828,6 +863,7 @@ export class Editor { // Edit button disabled after the event took place await this.addToggleEditButton(); await this.addEditButtonText(); + this._syncPlacementSnapTargets(null); } } @@ -890,7 +926,7 @@ export class Editor { public ClearControls() { // Remove all controls from the map - this._mapControls.forEach(control => this._map.removeControl(control)); + this._mapControls.forEach((control) => this._map.removeControl(control)); } private UpdateOnScreenDisplay(entity: MapEntity | null, customMsg: string = null) { @@ -917,10 +953,10 @@ export class Editor { private setupMapEvents(map: L.Map) { // When popup is closed, remove the fullscreen popup too. - // It's a bit backwards but since the close button on the fullscreen actually closes the popup, this makes it close the fullscreen too. + // It's a bit backwards but since the close button on the fullscreen actually closes the popup, this makes it close the fullscreen too. map.on('popupclose', function () { - var fullScreenPopup = document.getElementById("fullScreenPopup"); - fullScreenPopup.classList.add("hidden"); + var fullScreenPopup = document.getElementById('fullScreenPopup'); + fullScreenPopup.classList.add('hidden'); }); //Hide buffers when zoomed out @@ -934,10 +970,12 @@ export class Editor { }); // Hide name tooltips when zoomed out - this.groups['names'].getLayers().forEach(function (layer: any) { + this._groups['names'].getLayers().forEach(function (layer: any) { layer._tooltip.setOpacity(zoom >= 19 ? 1 : 0); }); - }); + + this._updateSnapOptions(); + }.bind(this)); // Add the event handler for newly created layers map.on('pm:create', this.onNewLayerCreated.bind(this)); @@ -952,7 +990,7 @@ export class Editor { private buildTooltipName(entity: MapEntity): string { let txt = entity.name; if (this._isEditMode) { - txt += "
" + entity.area + 'mΒ²'; + txt += '
' + entity.area + 'mΒ²'; } return txt; } diff --git a/src/entities/entity.ts b/src/entities/entity.ts index f6a59abe..110341b8 100644 --- a/src/entities/entity.ts +++ b/src/entities/entity.ts @@ -62,6 +62,11 @@ export class MapEntity implements EntityDTO { /** Extracts the GeoJson from the internal Leaflet layer to make sure its up-to-date */ private _calculateGeoJson() { + const polygonLayer = this.getEditablePolygonLayer(); + if (polygonLayer) { + return polygonLayer.toGeoJSON(); + } + //@ts-ignore let geoJson = this.layer.toGeoJSON(); @@ -74,6 +79,22 @@ export class MapEntity implements EntityDTO { return geoJson; } + /** The polygon Leaflet layer Geoman actually edits inside the GeoJSON wrapper. */ + public getEditablePolygonLayer(): L.Polygon | undefined { + const layers = (this.layer as L.GeoJSON).getLayers() as L.Polygon[]; + return layers.at(-1); + } + + /** Geoman can leave a stale polygon in the GeoJSON group after vertex edits. */ + public pruneToSinglePolygonLayer(keepLayer: L.Layer) { + const group = this.layer as L.GeoJSON; + for (const layer of [...group.getLayers()]) { + if (layer !== keepLayer) { + group.removeLayer(layer); + } + } + } + constructor(data: EntityDTO, rules: Array) { this.id = data.id; this._rules = rules; @@ -93,7 +114,7 @@ export class MapEntity implements EntityDTO { pmIgnore: false, interactive: true, bubblingMouseEvents: false, - snapIgnore: true, + snapIgnore: false, style: (/*feature*/) => this._getDefaultLayerStyle(), }); @@ -181,12 +202,18 @@ export class MapEntity implements EntityDTO { } public updateBufferedLayer() { - // Update the buffer layer so that its geometry is the same as this.layers geometry - const geoJson = this.layer.toGeoJSON(); + const polygonLayer = this.getEditablePolygonLayer(); + if (!polygonLayer) { + return; + } + + const geoJson = polygonLayer.toGeoJSON(); const buffered = Turf.buffer(geoJson, this._bufferWidth, { units: 'meters' }); const weight = this.getAllTriggeredRules().some((r) => r.shouldShowFireBuffer) ? 1 : 0; if (!this.bufferLayer) { this.bufferLayer = L.geoJSON(buffered, { + pmIgnore: true, + snapIgnore: true, style: { color: 'red', fillOpacity: 0.0, diff --git a/src/loaders/loadBaseLayers.ts b/src/loaders/loadBaseLayers.ts index c476c3b3..ee91a6e3 100644 --- a/src/loaders/loadBaseLayers.ts +++ b/src/loaders/loadBaseLayers.ts @@ -10,179 +10,193 @@ import { addPointsOfInterestsTomap } from './_addPOI'; import * as Turf from '@turf/turf'; export const loadBaseLayers = async (map: any, _isCleanAndQuietMode?: boolean) => { - // Add the Google Satellite layer if online, otherwise load the drawn map - //if (!window.navigator.onLine) { - //console.log("offline, loading local drawn map"); - await loadDrawnMap(map); - //map.addLayer(map.groups.drawnmap); - //} else{ - map.groups.googleSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { - maxZoom: 21, - maxNativeZoom: 20, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], - }).addTo(map); - //} - - // Load contours - fetch('./data/analysis/contours.geojson') - .then((response) => response.json()) - .then((response) => { - L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1, opacity: 0.5 } }).addTo( - map.groups.mapstuff, - ); - }); - - // Load low power area (for rules only, not visible) - map.groups.lowpowerarea = new L.LayerGroup(); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/low_power_area.geojson'); - - // Load reference drawings - // fetch('./data/analysis/references.geojson') - // .then((response) => response.json()) - // .then((response) => { - // L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1 } }).addTo(map.groups.mapstuff); - // }); - - // Loads: "slope", "parking", "closetosanctuary" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/placement_areas.geojson'); - // Loads "propertyborder", "naturereserve", "friends", "forbidden", "friends" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/borders.geojson'); - - // Loads "fireroads" - // with the fireroads as a reference, also load "publicplease" and "oktocamp" with a bigger buffer - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5 }); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { - buffer: 3.5, - propertyRenameFn: () => 'publicplease', - }); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { - buffer: 52.5, - propertyRenameFn: () => 'oktocamp', - }); - - // Loads "minorroad" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/walking_paths.geojson', { buffer: 1 }); - // Loads "plaza" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/plazas.geojson'); - // Loads "neighbourhood" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/neighbourhoods.geojson'); - - // Loads kids zones with feature-specific fill colors - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/kids_zones.geojson', { - propertyRenameFn: () => 'kidszones', - styleFn: (_value: string, feature: any) => getKidzoneStyle(feature), - }); - - // Load sound areas - await loadGeoJsonFeatureCollections(map, soundPropertyKey, 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=polygons', { - styleFn: (_value: string, feature: any) => getSoundStyle(feature), - }); + // Add the Google Satellite layer if online, otherwise load the drawn map + //if (!window.navigator.onLine) { + //console.log("offline, loading local drawn map"); + await loadDrawnMap(map); + //map.addLayer(map.groups.drawnmap); + //} else{ + map.groups.googleSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { + maxZoom: 21, + maxNativeZoom: 20, + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], + }).addTo(map); + //} + + // Load contours + fetch('./data/analysis/contours.geojson') + .then((response) => response.json()) + .then((response) => { + L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1, opacity: 0.5 } }).addTo( + map.groups.mapstuff, + ); + }); + + // Load low power area (for rules only, not visible) + map.groups.lowpowerarea = new L.LayerGroup(); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/low_power_area.geojson'); + + // Load reference drawings + // fetch('./data/analysis/references.geojson') + // .then((response) => response.json()) + // .then((response) => { + // L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1 } }).addTo(map.groups.mapstuff); + // }); + + // Loads: "slope", "parking", "closetosanctuary" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/placement_areas.geojson'); + // Loads "propertyborder", "naturereserve", "friends", "forbidden", "friends" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/borders.geojson'); + + // Loads "fireroads" + // with the fireroads as a reference, also load "publicplease" and "oktocamp" with a bigger buffer + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5 }); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: 3.5, + propertyRenameFn: () => 'publicplease', + }); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: 52.5, + propertyRenameFn: () => 'oktocamp', + }); + + // Loads "minorroad" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/walking_paths.geojson', { buffer: 1 }); + // Loads "plaza" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/plazas.geojson'); + // Loads "neighbourhood" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/neighbourhoods.geojson'); + + // Loads kids zones with feature-specific fill colors + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/kids_zones.geojson', { + propertyRenameFn: () => 'kidszones', + styleFn: (_value: string, feature: any) => getKidzoneStyle(feature), + }); + + // Load sound areas + await loadGeoJsonFeatureCollections( + map, + soundPropertyKey, + 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=polygons', + { + styleFn: (_value: string, feature: any) => getSoundStyle(feature), + }, + ); soundLayers.forEach((layer) => { map.groups[layer].addTo(map.groups.soundguide); }); - // Soundspots have to be added as a Feature, in order to have properties (For isBreakingSoundLimit) - await loadGeoJsonFeatureCollections(map, "type", 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', { - propertyRenameFn: () => soundSpotType, - buffer: 10, - styleFn: (_value: string, feature: any) => getSoundStyle(feature), - }); - map.groups[soundSpotType].addTo(map.groups.soundguide); - map.removeLayer(map.groups[soundSpotType]); - - // Add soundspots to the soundguide layer, needs to be after the feature which adds buffer, otherwise they are not clickable. - await addPointsOfInterestsTomap('https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', map.groups.soundspots, { - description: getSoundspotDescription, - link: '#page:soundspot', - }, _isCleanAndQuietMode); - - map.groups.soundspots.addTo(map.groups.soundguide); - map.removeLayer(map.groups.soundspots); - - await addPointsOfInterestsTomap('./data/bl26/poi.json', map.groups.poi, undefined, _isCleanAndQuietMode); - await addPowerGridTomap(map.groups.powergrid); - - // Load lakes with 50m buffer (for rules only, not visible) - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/lakes.geojson', { - buffer: 50, - }); - await addWellPOIToWaterProtectionArea(map); - - // Combine the Placement Area layers - map.groups.propertyborder.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.propertyborder); - map.groups.minorroad.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.minorroad); - map.groups.fireroad.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.fireroad); - map.groups.publicplease.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.publicplease); - map.groups.oktocamp.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.oktocamp); - //map.groups.closetosanctuary.addTo(map.groups.mapstuff); - //map.removeLayer(map.groups.closetosanctuary); - // map.groups.area.addTo(map.groups.mapstuff); - // map.removeLayer(map.groups.area); - // map.groups.hiddenforbidden.addTo(map.groups.mapstuff); - - // Add known objects - // Objects have no rules, they just draw small guiding shapes on the map - map.groups.parking.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.parking); - map.groups.bridge.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.bridge); - - //Create a layer group for areas where camping is not allowed - map.groups.hiddenforbidden = filterFeatures( - map.groups.neighbourhood, - (feature) => feature.properties && feature.properties.camping_allowed === false, - ); - - map.groups.terrain = await loadImageOverlay(map, './data/terrain.png', [ - [57.6156422900704257, 14.9150971736724536], - [57.6291230394961715, 14.9362178462290363], - ]); - - map.groups.heightmap = L.tileLayer('./data/analysis/height/{z}/{x}/{y}.jpg', { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 16, - maxNativeZoom: 17, - tms: false, - }); - - map.groups.slopemap = L.tileLayer('./data/analysis/slope/{z}/{x}/{y}.png', { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 16, - maxNativeZoom: 17, - tms: false, - }); - - const aftermathOptions = { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 15, - maxNativeZoom: 19, - tms: false, - }; - map.groups.aftermath25 = L.tileLayer('./data/bl25/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath24 = L.tileLayer('./data/bl24/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath23 = L.tileLayer('./data/bl23/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath22 = L.tileLayer('./data/bl22/aftermath/{z}/{x}/{y}.png', aftermathOptions); -}; + // Soundspots have to be added as a Feature, in order to have properties (For isBreakingSoundLimit) + await loadGeoJsonFeatureCollections( + map, + 'type', + 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', + { + propertyRenameFn: () => soundSpotType, + buffer: 10, + styleFn: (_value: string, feature: any) => getSoundStyle(feature), + }, + ); + map.groups[soundSpotType].addTo(map.groups.soundguide); + map.removeLayer(map.groups[soundSpotType]); + + // Add soundspots to the soundguide layer, needs to be after the feature which adds buffer, otherwise they are not clickable. + await addPointsOfInterestsTomap( + 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', + map.groups.soundspots, + { + description: getSoundspotDescription, + link: '#page:soundspot', + }, + _isCleanAndQuietMode, + ); + + map.groups.soundspots.addTo(map.groups.soundguide); + map.removeLayer(map.groups.soundspots); + + await addPointsOfInterestsTomap('./data/bl26/poi.json', map.groups.poi, undefined, _isCleanAndQuietMode); + await addPowerGridTomap(map.groups.powergrid); + + // Load lakes with 50m buffer (for rules only, not visible) + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/lakes.geojson', { + buffer: 50, + }); + await addWellPOIToWaterProtectionArea(map); + + // Combine the Placement Area layers + map.groups.propertyborder.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.propertyborder); + map.groups.minorroad.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.minorroad); + map.groups.fireroad.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.fireroad); + map.groups.publicplease.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.publicplease); + map.groups.oktocamp.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.oktocamp); + //map.groups.closetosanctuary.addTo(map.groups.mapstuff); + //map.removeLayer(map.groups.closetosanctuary); + // map.groups.area.addTo(map.groups.mapstuff); + // map.removeLayer(map.groups.area); + // map.groups.hiddenforbidden.addTo(map.groups.mapstuff); + + // Add known objects + // Objects have no rules, they just draw small guiding shapes on the map + map.groups.parking.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.parking); + map.groups.bridge.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.bridge); + + //Create a layer group for areas where camping is not allowed + map.groups.hiddenforbidden = filterFeatures( + map.groups.neighbourhood, + (feature) => feature.properties && feature.properties.camping_allowed === false, + ); + + map.groups.terrain = await loadImageOverlay(map, './data/terrain.png', [ + [57.6156422900704257, 14.9150971736724536], + [57.6291230394961715, 14.9362178462290363], + ]); + + map.groups.heightmap = L.tileLayer('./data/analysis/height/{z}/{x}/{y}.jpg', { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 16, + maxNativeZoom: 17, + tms: false, + }); + + map.groups.slopemap = L.tileLayer('./data/analysis/slope/{z}/{x}/{y}.png', { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 16, + maxNativeZoom: 17, + tms: false, + }); + const aftermathOptions = { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 15, + maxNativeZoom: 19, + tms: false, + }; + map.groups.aftermath25 = L.tileLayer('./data/bl25/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath24 = L.tileLayer('./data/bl24/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath23 = L.tileLayer('./data/bl23/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath22 = L.tileLayer('./data/bl22/aftermath/{z}/{x}/{y}.png', aftermathOptions); +}; async function addWellPOIToWaterProtectionArea(map: any) { - // Filter for water category POIs - const response = await fetch('./data/bl26/poi.json'); - const poiData = await response.json(); - const waterPois = poiData.features.filter((f: any) => f.properties.name === 'The Well'); - - // Apply 50m buffer and add to waterprotectionarea - waterPois.forEach((feature: any) => { - const buffered = Turf.buffer(feature, 50, { units: 'meters' }); - const layer = L.geoJSON(buffered, { interactive: false }); - map.groups.waterprotectionarea.addLayer(layer); - }); -} \ No newline at end of file + // Filter for water category POIs + const response = await fetch('./data/bl26/poi.json'); + const poiData = await response.json(); + const waterPois = poiData.features.filter((f: any) => f.properties.name === 'The Well'); + + // Apply 50m buffer and add to waterprotectionarea + waterPois.forEach((feature: any) => { + const buffered = Turf.buffer(feature, 50, { units: 'meters' }); + const layer = L.geoJSON(buffered, { interactive: false }); + map.groups.waterprotectionarea.addLayer(layer); + }); +} diff --git a/src/utils/snapDistance.ts b/src/utils/snapDistance.ts new file mode 100644 index 00000000..b0689487 --- /dev/null +++ b/src/utils/snapDistance.ts @@ -0,0 +1,13 @@ +import * as L from 'leaflet'; +import { SNAP_DISTANCE_METERS } from '../../SETTINGS'; + +/** Converts a ground distance in meters to screen pixels at the current map view. */ +export function metersToSnapPixels(map: L.Map, meters: number = SNAP_DISTANCE_METERS): number { + const center = map.getCenter(); + const latRad = (center.lat * Math.PI) / 180; + const metersPerDegreeLng = 111320 * Math.cos(latRad); + const offsetLng = meters / metersPerDegreeLng; + const origin = map.latLngToContainerPoint(center); + const offset = map.latLngToContainerPoint(L.latLng(center.lat, center.lng + offsetLng)); + return Math.max(1, Math.round(origin.distanceTo(offset))); +} From 0273603cbfbf40000a5fa967bb581251d8c0abc0 Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 16:22:06 +0200 Subject: [PATCH 03/10] Added snapping to include fireroads --- src/editor/editor.ts | 18 +++++++++++++++--- src/loaders/loadBaseLayers.ts | 5 ++--- src/loaders/loadGeoJsonFeatureCollections.ts | 7 +++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 6d9596d8..aad763b0 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -117,7 +117,7 @@ export class Editor { return; } - // Edit the shape of the entity β€” snap vertices to other camp areas only + // Edit the shape of the entity β€” snap vertices to other camp areas and fireroad edges if (this._mode == 'editing-shape' && nextEntity) { this._syncPlacementSnapTargets(nextEntity); nextEntity.layer.pm.enable({ @@ -606,6 +606,7 @@ export class Editor { // Add controls for creating and editing shapes to the map this._map.pm.addControls(this._getPmToolbarOptions(false)); this._updateSnapOptions(); + this._initFireroadSnapLayers(); // Set path style options for newly created layers this._map.pm.setPathOptions(LayerStyles.Default); @@ -659,7 +660,7 @@ export class Editor { }; } - /** Snap a dragged/placed vertex to corners and edges of other camp areas. */ + /** Snap a dragged/placed vertex to corners and edges of other camp areas and fireroad boundaries. */ private _getShapeEditSnapOptions() { return { snappable: true, @@ -680,7 +681,7 @@ export class Editor { }); } - /** Other camp areas are snap targets; optionally exclude the area being vertex-edited. */ + /** Camp areas are snap targets; optionally exclude the area being vertex-edited. */ private _syncPlacementSnapTargets(excludedEntity: MapEntity | null = null) { for (const entityId in this._currentRevisions) { const entity = this._currentRevisions[entityId]; @@ -693,6 +694,17 @@ export class Editor { } } + private _initFireroadSnapLayers() { + const fireroad = this._groups['fireroad'] as L.GeoJSON | undefined; + if (!fireroad) { + return; + } + + fireroad.eachLayer((layer) => { + L.PM.reInitLayer(layer); + }); + } + public hideWarningColors(hide: boolean = true) { this._hideWarningColors = hide; for (const entityid in this._currentRevisions) { diff --git a/src/loaders/loadBaseLayers.ts b/src/loaders/loadBaseLayers.ts index ee91a6e3..172ded7c 100644 --- a/src/loaders/loadBaseLayers.ts +++ b/src/loaders/loadBaseLayers.ts @@ -48,9 +48,8 @@ export const loadBaseLayers = async (map: any, _isCleanAndQuietMode?: boolean) = // Loads "propertyborder", "naturereserve", "friends", "forbidden", "friends" await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/borders.geojson'); - // Loads "fireroads" - // with the fireroads as a reference, also load "publicplease" and "oktocamp" with a bigger buffer - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5 }); + // Loads "fireroads" β€” buffered polygons; edges are used as snap targets when placing camps + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5, snapTarget: true }); await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 3.5, propertyRenameFn: () => 'publicplease', diff --git a/src/loaders/loadGeoJsonFeatureCollections.ts b/src/loaders/loadGeoJsonFeatureCollections.ts index bf86e443..f8a86820 100644 --- a/src/loaders/loadGeoJsonFeatureCollections.ts +++ b/src/loaders/loadGeoJsonFeatureCollections.ts @@ -15,6 +15,7 @@ import * as Turf from '@turf/turf'; * @param {number} operations.buffer - (Optional) Buffer radius to add around the features * @param {Function} operations.propertyRenameFn - (Optional) Function to rename groupByProperty value * @param {Function} operations.styleFn - (Optional) Function to style the features + * @param {boolean} operations.snapTarget - (Optional) Expose polygon edges as Geoman snap targets * @returns {Promise} */ export const loadGeoJsonFeatureCollections = async ( @@ -25,6 +26,7 @@ export const loadGeoJsonFeatureCollections = async ( buffer?: number; propertyRenameFn?: (value: string) => string; styleFn?: (value: string, feature: any) => L.PathOptions; + snapTarget?: boolean; } = {}, ) => { const response = await fetch(filename); @@ -55,6 +57,11 @@ export const loadGeoJsonFeatureCollections = async ( const geojsonLayer = L.geoJSON(geojsonData, { filter: filterByProperty(groupByProperty, value), style: operations.styleFn ? (feature) => operations.styleFn(value, feature) : () => getStyle(value), + onEachFeature: operations.snapTarget + ? (_feature, layer) => { + layer.options.snapIgnore = false; + } + : undefined, }); map.groups[value] = geojsonLayer; From 3ea997c6390c4489adabfaf1b63c8faac0577d03 Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 16:24:27 +0200 Subject: [PATCH 04/10] Updated settings.ts to include dev environment --- SETTINGS.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SETTINGS.ts b/SETTINGS.ts index c70e999c..606b38d7 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -3,9 +3,14 @@ */ // Repository + +// Points towards prod //export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; + +// For local development export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; + // Rules export const MAX_CLUSTER_SIZE: number = 1250; export const MAX_POWER_NEED: number = 8000; From 5c9d128be7c6ad2d07e9ca1382030480d2937e89 Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 19:15:26 +0200 Subject: [PATCH 05/10] Reverted automatic formatting --- SETTINGS.ts | 9 +- src/editor/editor.ts | 132 ++++++------- src/loaders/loadBaseLayers.ts | 351 ++++++++++++++++------------------ 3 files changed, 240 insertions(+), 252 deletions(-) diff --git a/SETTINGS.ts b/SETTINGS.ts index 606b38d7..d14e7282 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -4,12 +4,11 @@ // Repository -// Points towards prod -//export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; - -// For local development -export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; +//Repository (Point to prod) +export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; +//Repository (Point to dev) +//export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; // Rules export const MAX_CLUSTER_SIZE: number = 1250; diff --git a/src/editor/editor.ts b/src/editor/editor.ts index aad763b0..0c9fdb08 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -12,7 +12,7 @@ import 'leaflet-search'; import { EditorPopup } from './editorPopup'; import { AdminAPI } from './adminAPI'; import { HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY } from '../../SETTINGS'; -import { setCookie, getCookie } from '../utils/cookie'; +import { setCookie, getCookie } from "../utils/cookie"; import { metersToSnapPixels } from '../utils/snapDistance'; /** @@ -30,7 +30,7 @@ export class Editor { private _popup: L.Popup; private _editorPopup: EditorPopup; /** If the editor should be active or not */ - private _isEditMode: boolean = true; + private _isEditMode: boolean = false; // This will skip checking entity rules, hide controls and hide messages. private _isCleanAndQuietMode: boolean; @@ -72,7 +72,13 @@ export class Editor { // When blur is sent as parameter, the next mode is dynamicly determined if (nextMode == 'blur') { - if ((prevMode == 'editing-shape' || prevMode == 'moving-shape') && prevEntity) { + if ( + ( + prevMode == 'editing-shape' + || prevMode == 'moving-shape' + ) + && prevEntity + ) { nextMode = 'selected'; nextEntity = nextEntity || prevEntity; //re-center the pop up on the new layer, in case the layer has moved @@ -117,7 +123,7 @@ export class Editor { return; } - // Edit the shape of the entity β€” snap vertices to other camp areas and fireroad edges + // Edit the shape of the entity if (this._mode == 'editing-shape' && nextEntity) { this._syncPlacementSnapTargets(nextEntity); nextEntity.layer.pm.enable({ @@ -129,7 +135,7 @@ export class Editor { this.setPopup('none'); return; } - // Move the shape of the entity β€” whole-shape drag, no vertex snapping + // Move the shape of the entity if (this._mode == 'moving-shape' && nextEntity) { this._syncPlacementSnapTargets(null); this.setSelected(nextEntity); @@ -182,13 +188,13 @@ export class Editor { // Move this logic to the entityInfoEditor? let editEntityCallback = async (action: string, extraInfo?: string) => { switch (action) { - case 'delete': + case "delete": this.deleteAndRemoveEntity(this._selected, extraInfo); - Messages.showNotification('Deleted', 'success'); + Messages.showNotification("Deleted", 'success'); this._popup.close(); break; - case 'save': + case "save": if (!this._selected.hasChanges()) { break; } @@ -199,7 +205,7 @@ export class Editor { this.setPopup('info', entityInResponse); break; - case 'restore-shape': + case "restore-shape": // First remove currently drawn shape to avoid duplicates this.removeEntityNameTooltip(this._selected); this.removeEntityFromLayers(this._selected); @@ -238,20 +244,20 @@ export class Editor { this._compareRevDiffLayer, editEntityCallback.bind(this), ); - var fullScreenPopup = document.getElementById('fullScreenPopup'); - fullScreenPopup.innerHTML = ''; // Need to remove the old info + var fullScreenPopup = document.getElementById("fullScreenPopup"); + fullScreenPopup.innerHTML = ""; // Need to remove the old info fullScreenPopup.appendChild(contentFullScreenPopup); fullScreenPopup.classList.remove('hidden'); // Add a close button to the fullScreenPopup - let span = document.createElement('span'); - let closeButton = document.createElement('sl-icon'); - closeButton.style.margin = '5px 5px 5px 10px'; - closeButton.style.fontSize = '20px'; - closeButton.setAttribute('name', 'x-lg'); // sets the icon + let span = document.createElement("span"); + let closeButton = document.createElement("sl-icon"); + closeButton.style.margin = "5px 5px 5px 10px"; + closeButton.style.fontSize = "20px"; + closeButton.setAttribute("name", "x-lg"); // sets the icon closeButton.onclick = () => { - this.setMode('none'); + this.setMode("none"); }; - let header = fullScreenPopup.querySelector('header'); + let header = fullScreenPopup.querySelector("header"); span.appendChild(closeButton); header.appendChild(span); @@ -285,7 +291,8 @@ export class Editor { } let latestEntity = entity.revisions[latestKey]; if (entity.revision != latestEntity.revision) { - let diffDescription: string = `Someone else edited this shape at the same time as you
+ let diffDescription: string = + `Someone else edited this shape at the same time as you
You have now overwritten their changes, see the differences in the history tab.`; Messages.showNotification(diffDescription, 'danger', undefined, 3600000); } @@ -400,7 +407,7 @@ export class Editor { if (e.layer._rings.length == 0) { this.deleteAndRemoveEntity(this._selected, 'No vertex remaining, automatic deletion of entity'); - return; + return } entity.updateBufferedLayer(); @@ -503,7 +510,7 @@ export class Editor { let hideWarnings = this._hideWarningColors || this._isCleanAndQuietMode; for (const entity of batch) { entity.checkAllRules(); - entity.setLayerStyle('severity', hideWarnings); + entity.setLayerStyle("severity", hideWarnings); } requestAnimationFrame(this.checkRulesSlowly.bind(this)); @@ -632,15 +639,13 @@ export class Editor { // Add search control if (!this._isCleanAndQuietMode) { //@ts-ignore - map.addControl( - new L.Control.Search({ - layer: this._placementLayers, - propertyName: 'name', - marker: false, - zoom: 19, - initial: false, - }), - ); + map.addControl(new L.Control.Search({ + layer: this._placementLayers, + propertyName: 'name', + marker: false, + zoom: 19, + initial: false, + })); } } @@ -660,7 +665,6 @@ export class Editor { }; } - /** Snap a dragged/placed vertex to corners and edges of other camp areas and fireroad boundaries. */ private _getShapeEditSnapOptions() { return { snappable: true, @@ -681,7 +685,6 @@ export class Editor { }); } - /** Camp areas are snap targets; optionally exclude the area being vertex-edited. */ private _syncPlacementSnapTargets(excludedEntity: MapEntity | null = null) { for (const entityId in this._currentRevisions) { const entity = this._currentRevisions[entityId]; @@ -709,35 +712,34 @@ export class Editor { this._hideWarningColors = hide; for (const entityid in this._currentRevisions) { let entity = this._currentRevisions[entityid]; - entity.setLayerStyle('severity', this._hideWarningColors); + entity.setLayerStyle("severity", this._hideWarningColors); } } private async addToggleEditButton() { // Edit button might be still shown in users browser because of cache, so lets check if editing actually is possible. if (await AdminAPI.isEditAllowed()) { - this._map.addControl( - ButtonsFactory.edit(this._isEditMode, async () => { - // This callback should return true if edit mode should be toggled on, false if not. - if (!this._isEditMode) { - const isSecretSet = await AdminAPI.isEditButtonSecretSet(); - - if (isSecretSet) { - const pw = prompt('Password? 🀐'); - if (pw == null || pw.trim() === '') return false; - - const success = await AdminAPI.CheckIfSecretIsSet(pw); - if (!success) { - alert('Wrong password! 😒'); - return false; - } + this._map.addControl(ButtonsFactory.edit(this._isEditMode, async () => { + // This callback should return true if edit mode should be toggled on, false if not. + if (!this._isEditMode) { + const isSecretSet = await AdminAPI.isEditButtonSecretSet(); + + if (isSecretSet) { + const pw = prompt('Password? 🀐'); + if (pw == null || pw.trim() === '') + return false; + + const success = await AdminAPI.CheckIfSecretIsSet(pw); + if (!success) { + alert('Wrong password! 😒'); + return false; } } + } - this.toggleEditMode(); - return true; - }), - ); + this.toggleEditMode(); + return true; + })); // Auto click the button to enable edit mode setTimeout(() => { //document.querySelector('.btn.button-shake-animate.leaflet-control').click(); @@ -754,7 +756,7 @@ export class Editor { public async toggleEditMode() { // Doublecheck if editing still is allowed. - if (!(await AdminAPI.isEditAllowed())) { + if (!await AdminAPI.isEditAllowed()) { this._isEditMode = false; return; // Perhaps remove the button or show a message? @@ -805,9 +807,9 @@ export class Editor { }; // This function is called when the user starts drawing a new polygon, it adds the distance to the tooltip - this._map.on('pm:drawstart', ({ workingLayer }) => { + this._map.on("pm:drawstart", ({ workingLayer }) => { // calculate the distance between the latest and previous vertices - workingLayer.on('pm:vertexadded', (e) => { + workingLayer.on("pm:vertexadded", (e) => { let coords = e.workingLayer._latlngs; if (coords.length < 2) { return; @@ -827,12 +829,12 @@ export class Editor { writeDistanceOnTooltip(distance); }; - workingLayer.on('pm:snapdrag', snapdragFn); + workingLayer.on("pm:snapdrag", snapdragFn); // Used when the vertex gets snapped to another vertex, otherwise the distance is calculated from the mouse position - workingLayer.on('pm:snap', (e) => { + workingLayer.on("pm:snap", (e) => { // toggle off the snapdrag event - workingLayer.off('pm:snapdrag', snapdragFn); + workingLayer.off("pm:snapdrag", snapdragFn); let coords = e.workingLayer._latlngs; let prev = coords[coords.length - 1]; let next = e.snapLatLng; @@ -841,8 +843,8 @@ export class Editor { }); // toggle on the snapdrag event again - workingLayer.on('pm:unsnap', (e) => { - workingLayer.on('pm:snapdrag', snapdragFn); + workingLayer.on("pm:unsnap", (e) => { + workingLayer.on("pm:snapdrag", snapdragFn); }); }); } @@ -938,7 +940,7 @@ export class Editor { public ClearControls() { // Remove all controls from the map - this._mapControls.forEach((control) => this._map.removeControl(control)); + this._mapControls.forEach(control => this._map.removeControl(control)); } private UpdateOnScreenDisplay(entity: MapEntity | null, customMsg: string = null) { @@ -965,10 +967,10 @@ export class Editor { private setupMapEvents(map: L.Map) { // When popup is closed, remove the fullscreen popup too. - // It's a bit backwards but since the close button on the fullscreen actually closes the popup, this makes it close the fullscreen too. + // It's a bit backwards but since the close button on the fullscreen actually closes the popup, this makes it close the fullscreen too. map.on('popupclose', function () { - var fullScreenPopup = document.getElementById('fullScreenPopup'); - fullScreenPopup.classList.add('hidden'); + var fullScreenPopup = document.getElementById("fullScreenPopup"); + fullScreenPopup.classList.add("hidden"); }); //Hide buffers when zoomed out @@ -1002,7 +1004,7 @@ export class Editor { private buildTooltipName(entity: MapEntity): string { let txt = entity.name; if (this._isEditMode) { - txt += '
' + entity.area + 'mΒ²'; + txt += "
" + entity.area + 'mΒ²'; } return txt; } diff --git a/src/loaders/loadBaseLayers.ts b/src/loaders/loadBaseLayers.ts index 172ded7c..f746857b 100644 --- a/src/loaders/loadBaseLayers.ts +++ b/src/loaders/loadBaseLayers.ts @@ -10,192 +10,179 @@ import { addPointsOfInterestsTomap } from './_addPOI'; import * as Turf from '@turf/turf'; export const loadBaseLayers = async (map: any, _isCleanAndQuietMode?: boolean) => { - // Add the Google Satellite layer if online, otherwise load the drawn map - //if (!window.navigator.onLine) { - //console.log("offline, loading local drawn map"); - await loadDrawnMap(map); - //map.addLayer(map.groups.drawnmap); - //} else{ - map.groups.googleSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { - maxZoom: 21, - maxNativeZoom: 20, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], - }).addTo(map); - //} - - // Load contours - fetch('./data/analysis/contours.geojson') - .then((response) => response.json()) - .then((response) => { - L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1, opacity: 0.5 } }).addTo( - map.groups.mapstuff, - ); - }); - - // Load low power area (for rules only, not visible) - map.groups.lowpowerarea = new L.LayerGroup(); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/low_power_area.geojson'); - - // Load reference drawings - // fetch('./data/analysis/references.geojson') - // .then((response) => response.json()) - // .then((response) => { - // L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1 } }).addTo(map.groups.mapstuff); - // }); - - // Loads: "slope", "parking", "closetosanctuary" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/placement_areas.geojson'); - // Loads "propertyborder", "naturereserve", "friends", "forbidden", "friends" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/borders.geojson'); - - // Loads "fireroads" β€” buffered polygons; edges are used as snap targets when placing camps - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5, snapTarget: true }); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { - buffer: 3.5, - propertyRenameFn: () => 'publicplease', - }); - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { - buffer: 52.5, - propertyRenameFn: () => 'oktocamp', - }); - - // Loads "minorroad" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/walking_paths.geojson', { buffer: 1 }); - // Loads "plaza" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/plazas.geojson'); - // Loads "neighbourhood" - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/neighbourhoods.geojson'); - - // Loads kids zones with feature-specific fill colors - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/kids_zones.geojson', { - propertyRenameFn: () => 'kidszones', - styleFn: (_value: string, feature: any) => getKidzoneStyle(feature), - }); - - // Load sound areas - await loadGeoJsonFeatureCollections( - map, - soundPropertyKey, - 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=polygons', - { - styleFn: (_value: string, feature: any) => getSoundStyle(feature), - }, - ); + // Add the Google Satellite layer if online, otherwise load the drawn map + //if (!window.navigator.onLine) { + //console.log("offline, loading local drawn map"); + await loadDrawnMap(map); + //map.addLayer(map.groups.drawnmap); + //} else{ + map.groups.googleSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { + maxZoom: 21, + maxNativeZoom: 20, + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], + }).addTo(map); + //} + + // Load contours + fetch('./data/analysis/contours.geojson') + .then((response) => response.json()) + .then((response) => { + L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1, opacity: 0.5 } }).addTo( + map.groups.mapstuff, + ); + }); + + // Load low power area (for rules only, not visible) + map.groups.lowpowerarea = new L.LayerGroup(); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/low_power_area.geojson'); + + // Load reference drawings + // fetch('./data/analysis/references.geojson') + // .then((response) => response.json()) + // .then((response) => { + // L.geoJSON(response.features, { style: { color: '#ffffff', weight: 1 } }).addTo(map.groups.mapstuff); + // }); + + // Loads: "slope", "parking", "closetosanctuary" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/placement_areas.geojson'); + // Loads "propertyborder", "naturereserve", "friends", "forbidden", "friends" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/borders.geojson'); + + // Loads "fireroads" + // with the fireroads as a reference, also load "publicplease" and "oktocamp" with a bigger buffer + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5, snapTarget: true }); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: 3.5, + propertyRenameFn: () => 'publicplease', + }); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: 52.5, + propertyRenameFn: () => 'oktocamp', + }); + + // Loads "minorroad" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/walking_paths.geojson', { buffer: 1 }); + // Loads "plaza" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/plazas.geojson'); + // Loads "neighbourhood" + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/neighbourhoods.geojson'); + + // Loads kids zones with feature-specific fill colors + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/kids_zones.geojson', { + propertyRenameFn: () => 'kidszones', + styleFn: (_value: string, feature: any) => getKidzoneStyle(feature), + }); + + // Load sound areas + await loadGeoJsonFeatureCollections(map, soundPropertyKey, 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=polygons', { + styleFn: (_value: string, feature: any) => getSoundStyle(feature), + }); soundLayers.forEach((layer) => { map.groups[layer].addTo(map.groups.soundguide); }); - // Soundspots have to be added as a Feature, in order to have properties (For isBreakingSoundLimit) - await loadGeoJsonFeatureCollections( - map, - 'type', - 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', - { - propertyRenameFn: () => soundSpotType, - buffer: 10, - styleFn: (_value: string, feature: any) => getSoundStyle(feature), - }, - ); - map.groups[soundSpotType].addTo(map.groups.soundguide); - map.removeLayer(map.groups[soundSpotType]); - - // Add soundspots to the soundguide layer, needs to be after the feature which adds buffer, otherwise they are not clickable. - await addPointsOfInterestsTomap( - 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', - map.groups.soundspots, - { - description: getSoundspotDescription, - link: '#page:soundspot', - }, - _isCleanAndQuietMode, - ); - - map.groups.soundspots.addTo(map.groups.soundguide); - map.removeLayer(map.groups.soundspots); - - await addPointsOfInterestsTomap('./data/bl26/poi.json', map.groups.poi, undefined, _isCleanAndQuietMode); - await addPowerGridTomap(map.groups.powergrid); - - // Load lakes with 50m buffer (for rules only, not visible) - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/lakes.geojson', { - buffer: 50, - }); - await addWellPOIToWaterProtectionArea(map); - - // Combine the Placement Area layers - map.groups.propertyborder.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.propertyborder); - map.groups.minorroad.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.minorroad); - map.groups.fireroad.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.fireroad); - map.groups.publicplease.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.publicplease); - map.groups.oktocamp.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.oktocamp); - //map.groups.closetosanctuary.addTo(map.groups.mapstuff); - //map.removeLayer(map.groups.closetosanctuary); - // map.groups.area.addTo(map.groups.mapstuff); - // map.removeLayer(map.groups.area); - // map.groups.hiddenforbidden.addTo(map.groups.mapstuff); - - // Add known objects - // Objects have no rules, they just draw small guiding shapes on the map - map.groups.parking.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.parking); - map.groups.bridge.addTo(map.groups.mapstuff); - map.removeLayer(map.groups.bridge); - - //Create a layer group for areas where camping is not allowed - map.groups.hiddenforbidden = filterFeatures( - map.groups.neighbourhood, - (feature) => feature.properties && feature.properties.camping_allowed === false, - ); - - map.groups.terrain = await loadImageOverlay(map, './data/terrain.png', [ - [57.6156422900704257, 14.9150971736724536], - [57.6291230394961715, 14.9362178462290363], - ]); - - map.groups.heightmap = L.tileLayer('./data/analysis/height/{z}/{x}/{y}.jpg', { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 16, - maxNativeZoom: 17, - tms: false, - }); - - map.groups.slopemap = L.tileLayer('./data/analysis/slope/{z}/{x}/{y}.png', { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 16, - maxNativeZoom: 17, - tms: false, - }); - - const aftermathOptions = { - minZoom: 13, - maxZoom: 21, - minNativeZoom: 15, - maxNativeZoom: 19, - tms: false, - }; - map.groups.aftermath25 = L.tileLayer('./data/bl25/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath24 = L.tileLayer('./data/bl24/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath23 = L.tileLayer('./data/bl23/aftermath/{z}/{x}/{y}.png', aftermathOptions); - map.groups.aftermath22 = L.tileLayer('./data/bl22/aftermath/{z}/{x}/{y}.png', aftermathOptions); + // Soundspots have to be added as a Feature, in order to have properties (For isBreakingSoundLimit) + await loadGeoJsonFeatureCollections(map, "type", 'https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', { + propertyRenameFn: () => soundSpotType, + buffer: 10, + styleFn: (_value: string, feature: any) => getSoundStyle(feature), + }); + map.groups[soundSpotType].addTo(map.groups.soundguide); + map.removeLayer(map.groups[soundSpotType]); + + // Add soundspots to the soundguide layer, needs to be after the feature which adds buffer, otherwise they are not clickable. + await addPointsOfInterestsTomap('https://alversjomaps.vercel.app/geoapi/maps/map2?features=points', map.groups.soundspots, { + description: getSoundspotDescription, + link: '#page:soundspot', + }, _isCleanAndQuietMode); + + map.groups.soundspots.addTo(map.groups.soundguide); + map.removeLayer(map.groups.soundspots); + + await addPointsOfInterestsTomap('./data/bl26/poi.json', map.groups.poi, undefined, _isCleanAndQuietMode); + await addPowerGridTomap(map.groups.powergrid); + + // Load lakes with 50m buffer (for rules only, not visible) + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/lakes.geojson', { + buffer: 50, + }); + await addWellPOIToWaterProtectionArea(map); + + // Combine the Placement Area layers + map.groups.propertyborder.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.propertyborder); + map.groups.minorroad.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.minorroad); + map.groups.fireroad.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.fireroad); + map.groups.publicplease.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.publicplease); + map.groups.oktocamp.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.oktocamp); + //map.groups.closetosanctuary.addTo(map.groups.mapstuff); + //map.removeLayer(map.groups.closetosanctuary); + // map.groups.area.addTo(map.groups.mapstuff); + // map.removeLayer(map.groups.area); + // map.groups.hiddenforbidden.addTo(map.groups.mapstuff); + + // Add known objects + // Objects have no rules, they just draw small guiding shapes on the map + map.groups.parking.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.parking); + map.groups.bridge.addTo(map.groups.mapstuff); + map.removeLayer(map.groups.bridge); + + //Create a layer group for areas where camping is not allowed + map.groups.hiddenforbidden = filterFeatures( + map.groups.neighbourhood, + (feature) => feature.properties && feature.properties.camping_allowed === false, + ); + + map.groups.terrain = await loadImageOverlay(map, './data/terrain.png', [ + [57.6156422900704257, 14.9150971736724536], + [57.6291230394961715, 14.9362178462290363], + ]); + + map.groups.heightmap = L.tileLayer('./data/analysis/height/{z}/{x}/{y}.jpg', { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 16, + maxNativeZoom: 17, + tms: false, + }); + + map.groups.slopemap = L.tileLayer('./data/analysis/slope/{z}/{x}/{y}.png', { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 16, + maxNativeZoom: 17, + tms: false, + }); + + const aftermathOptions = { + minZoom: 13, + maxZoom: 21, + minNativeZoom: 15, + maxNativeZoom: 19, + tms: false, + }; + map.groups.aftermath25 = L.tileLayer('./data/bl25/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath24 = L.tileLayer('./data/bl24/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath23 = L.tileLayer('./data/bl23/aftermath/{z}/{x}/{y}.png', aftermathOptions); + map.groups.aftermath22 = L.tileLayer('./data/bl22/aftermath/{z}/{x}/{y}.png', aftermathOptions); }; + async function addWellPOIToWaterProtectionArea(map: any) { - // Filter for water category POIs - const response = await fetch('./data/bl26/poi.json'); - const poiData = await response.json(); - const waterPois = poiData.features.filter((f: any) => f.properties.name === 'The Well'); - - // Apply 50m buffer and add to waterprotectionarea - waterPois.forEach((feature: any) => { - const buffered = Turf.buffer(feature, 50, { units: 'meters' }); - const layer = L.geoJSON(buffered, { interactive: false }); - map.groups.waterprotectionarea.addLayer(layer); - }); -} + // Filter for water category POIs + const response = await fetch('./data/bl26/poi.json'); + const poiData = await response.json(); + const waterPois = poiData.features.filter((f: any) => f.properties.name === 'The Well'); + + // Apply 50m buffer and add to waterprotectionarea + waterPois.forEach((feature: any) => { + const buffered = Turf.buffer(feature, 50, { units: 'meters' }); + const layer = L.geoJSON(buffered, { interactive: false }); + map.groups.waterprotectionarea.addLayer(layer); + }); +} \ No newline at end of file From da92a39f3a9c71f9764075bd44dfd08161600a3c Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 19:29:47 +0200 Subject: [PATCH 06/10] Added copy on snapping to guide --- SETTINGS.ts | 5 ----- public/drawers/guide-usage.html | 5 ++++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/SETTINGS.ts b/SETTINGS.ts index d14e7282..11b983d5 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -3,13 +3,8 @@ */ // Repository - -//Repository (Point to prod) export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; -//Repository (Point to dev) -//export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; - // Rules export const MAX_CLUSTER_SIZE: number = 1250; export const MAX_POWER_NEED: number = 8000; diff --git a/public/drawers/guide-usage.html b/public/drawers/guide-usage.html index 67229aa8..0cb945ee 100644 --- a/public/drawers/guide-usage.html +++ b/public/drawers/guide-usage.html @@ -1,7 +1,10 @@

Your Placement ToolsQuick help

  1. Click the "Edit" button in the lower left
  2. -
  3. Start drawing a shape and close it by clicking on the first point
  4. +
  5. + Start drawing a shape and close it by clicking on the first point. While drawing, points will snap to a target + within 2m. Hold down ALT while drawing to disable snapping. +
  6. A box will pop up asking for your camp's information. Fill in all fields, including power needs, sound levels, and your contact information (so people can collaborate with you!) From 104830de199532cab552a0dc7ddff163d045458e Mon Sep 17 00:00:00 2001 From: Carl Date: Mon, 25 May 2026 19:44:52 +0200 Subject: [PATCH 07/10] Reverted deletion bug changes to match main --- src/editor/editor.ts | 9 +++++---- src/entities/entity.ts | 29 ++--------------------------- 2 files changed, 7 insertions(+), 31 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 0c9fdb08..b8c0b041 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -403,8 +403,6 @@ export class Editor { // Update the buffered layer when the layer has a vertex removed entity.layer.on('pm:vertexremoved', (e) => { - entity.pruneToSinglePolygonLayer(e.layer); - if (e.layer._rings.length == 0) { this.deleteAndRemoveEntity(this._selected, 'No vertex remaining, automatic deletion of entity'); return @@ -533,9 +531,12 @@ export class Editor { } private deleteAndRemoveEntity(entity: MapEntity, deleteReason: string = null) { - this._selected = null; // Dont think this is needed for setMode function to work with 'none' (not fully tested this scenario though) - this.setMode('none'); + this._selected = null; this.removeEntity(entity); + // Avoid setMode('none') here β€” it runs _syncPlacementSnapTargets / L.PM.reInitLayer + // on camps still on the map, which can leave a ghost polygon after delete. + this._mode = 'none'; + this.setPopup('none'); this._repository.deleteEntity(entity, deleteReason); } diff --git a/src/entities/entity.ts b/src/entities/entity.ts index 110341b8..9f06ec26 100644 --- a/src/entities/entity.ts +++ b/src/entities/entity.ts @@ -62,11 +62,6 @@ export class MapEntity implements EntityDTO { /** Extracts the GeoJson from the internal Leaflet layer to make sure its up-to-date */ private _calculateGeoJson() { - const polygonLayer = this.getEditablePolygonLayer(); - if (polygonLayer) { - return polygonLayer.toGeoJSON(); - } - //@ts-ignore let geoJson = this.layer.toGeoJSON(); @@ -79,22 +74,6 @@ export class MapEntity implements EntityDTO { return geoJson; } - /** The polygon Leaflet layer Geoman actually edits inside the GeoJSON wrapper. */ - public getEditablePolygonLayer(): L.Polygon | undefined { - const layers = (this.layer as L.GeoJSON).getLayers() as L.Polygon[]; - return layers.at(-1); - } - - /** Geoman can leave a stale polygon in the GeoJSON group after vertex edits. */ - public pruneToSinglePolygonLayer(keepLayer: L.Layer) { - const group = this.layer as L.GeoJSON; - for (const layer of [...group.getLayers()]) { - if (layer !== keepLayer) { - group.removeLayer(layer); - } - } - } - constructor(data: EntityDTO, rules: Array) { this.id = data.id; this._rules = rules; @@ -202,12 +181,8 @@ export class MapEntity implements EntityDTO { } public updateBufferedLayer() { - const polygonLayer = this.getEditablePolygonLayer(); - if (!polygonLayer) { - return; - } - - const geoJson = polygonLayer.toGeoJSON(); + // Update the buffer layer so that its geometry is the same as this.layers geometry + const geoJson = this.layer.toGeoJSON(); const buffered = Turf.buffer(geoJson, this._bufferWidth, { units: 'meters' }); const weight = this.getAllTriggeredRules().some((r) => r.shouldShowFireBuffer) ? 1 : 0; if (!this.bufferLayer) { From 5f639be4cd057dcdc519e28264fc3eaade68e6a8 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 27 May 2026 08:40:57 +0200 Subject: [PATCH 08/10] Added small nudging to snapping towards fireroads and other camps, removed errors --- SETTINGS.ts | 21 +- src/editor/editor.ts | 135 ++++++++--- src/entities/ClusterCache.ts | 6 +- src/entities/entity.ts | 60 ++++- src/loaders/loadBaseLayers.ts | 18 +- src/rule/index.ts | 11 +- .../rules/isBufferOverlappingRecursive.ts | 80 ++++--- src/rule/rules/isOverlapping.ts | 81 ++++--- src/rule/rules/isOverlappingOrContained.ts | 69 +++--- src/rule/rules/utils.ts | 211 +++++++++++++++++- src/types/geojson.ts | 8 + src/types/leaflet-geoman.d.ts | 38 ++++ src/types/leafletHelpers.ts | 36 +++ src/utils/campSnapOutline.ts | 39 ++++ tsconfig.json | 3 +- 15 files changed, 672 insertions(+), 144 deletions(-) create mode 100644 src/types/geojson.ts create mode 100644 src/types/leaflet-geoman.d.ts create mode 100644 src/types/leafletHelpers.ts create mode 100644 src/utils/campSnapOutline.ts diff --git a/SETTINGS.ts b/SETTINGS.ts index 11b983d5..b9d10008 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -3,14 +3,33 @@ */ // Repository -export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; +//export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; +export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; // Rules export const MAX_CLUSTER_SIZE: number = 1250; export const MAX_POWER_NEED: number = 8000; export const MAX_POINTS_BEFORE_WARNING: number = 10; export const FIRE_BUFFER_IN_METER: number = 5; +/** Max snap distance when snapping to fireroads (m). */ export const SNAP_DISTANCE_METERS: number = 2; +/** Max snap distance when snapping to other camps (m) β€” tighter than fireroads. */ +export const CAMP_SNAP_DISTANCE_METERS: number = 1; +/** Fireroad centerline buffer used for placement rules (m). */ +export const FIREROAD_CLEARANCE_METERS: number = 2.5; +/** Extra buffer for snap targets so vertices land outside the clearance polygon (m). */ +export const FIREROAD_SNAP_OUTSET_METERS: number = 0.1; +/** Shrink zone polygons before clearance overlap tests (fireroad, slope, etc.) (m). */ +export const SNAP_EDGE_TOLERANCE_METERS: number = 1; +/** Offset outward from a camp edge when snapping (m). */ +export const CAMP_SNAP_GAP_METERS: number = 0.2; +/** + * Camp-vs-camp: any shared area above this (mΒ²) is invalid. Kept tiny for float noise only. + * Touching without overlapping has ~0 mΒ² intersection and is allowed. + */ +export const MIN_CAMP_AREA_OVERLAP_SQM: number = 0.05; +/** Min overlap (mΒ²) for zone rules (fireroad buffers, etc.), not camp-vs-camp. */ +export const MIN_CAMP_OVERLAP_AREA_SQM: number = 1; export const TOTAL_MEMBERSHIPS_SOLD = 5432; // 2026, this is used for the stats page. export const SOUND_GUIDE_URL = 'https://docs.google.com/document/d/1aDBv3UWOxngdjWd_z4N34Wcm7r7GvD-gINGwQIr4ti8'; diff --git a/src/editor/editor.ts b/src/editor/editor.ts index b8c0b041..b9ed893f 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -11,9 +11,15 @@ import 'leaflet.path.drag'; import 'leaflet-search'; import { EditorPopup } from './editorPopup'; import { AdminAPI } from './adminAPI'; -import { HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY } from '../../SETTINGS'; +import { + CAMP_SNAP_DISTANCE_METERS, + HAS_SEEN_EDITOR_INSTRUCTIONS_COOKIE_KEY, + SNAP_DISTANCE_METERS, +} from '../../SETTINGS'; import { setCookie, getCookie } from "../utils/cookie"; import { metersToSnapPixels } from '../utils/snapDistance'; +import { createCampSnapOutlineLayer } from '../utils/campSnapOutline'; +import { getGeoJsonChildLayer, getWorkingLayerLatLngs } from '../types/leafletHelpers'; /** * The Editor class keeps track of the user status regarding editing and @@ -47,6 +53,8 @@ export class Editor { private _placementLayers: L.LayerGroup; private _placementBufferLayers: L.LayerGroup; private _compareRevDiffLayer: L.LayerGroup; + private _campSnapOutlines: L.LayerGroup; + private _campSnapOutlineById: Record = {}; private _lastEnityFetch: number; private _autoRefreshIntervall: number; @@ -118,7 +126,7 @@ export class Editor { // Stop any ongoing editing of the previously selected layer if (prevEntity) { prevEntity?.layer.pm.disable(); - prevEntity?.layer._layers[prevEntity.layer._leaflet_id - 1].dragging.disable(); + getGeoJsonChildLayer(prevEntity.layer)?.dragging?.disable(); } return; @@ -130,7 +138,7 @@ export class Editor { editMode: true, allowSelfIntersection: false, ...this._getShapeEditSnapOptions(), - }); + } as Parameters['enable']>[0]); this.setSelected(nextEntity); this.setPopup('none'); return; @@ -141,7 +149,7 @@ export class Editor { this.setSelected(nextEntity); this.setPopup('none'); this.UpdateOnScreenDisplay(nextEntity, 'Drag to move'); - nextEntity.layer._layers[nextEntity.layer._leaflet_id - 1].dragging.enable(); + getGeoJsonChildLayer(nextEntity.layer)?.dragging?.enable(); return; } // Edit the info of the entity @@ -333,9 +341,14 @@ export class Editor { // Remove the drawn layer and replace it with one bound to the entity if (entityInResponse) { - this.addEntityToMap(entityInResponse); + // Geoman adds finished draws to _placementLayers β€” remove before rule checks + // or the new camp will overlap its own temporary duplicate geometry. + this._placementLayers.removeLayer(layer); this._map.removeLayer(layer); + this.addEntityToMap(entityInResponse); + this._syncPlacementSnapTargets(null); + //@ts-ignore const bounds = entityInResponse.layer.getBounds(); const latlng = bounds.getCenter(); @@ -394,22 +407,35 @@ export class Editor { entity.layer.on('pm:markerdragend', () => { this.refreshEntity(entity); this.isAreaTooBig(entity.toGeoJSON()); + this._updateCampSnapOutline(entity, this._isCampSnapOutlineExcluded(entity)); }); - entity.layer._layers[entity.layer._leaflet_id - 1].on('drag', () => { + getGeoJsonChildLayer(entity.layer)?.on('drag', () => { entity.updateBufferedLayer(); this.UpdateOnScreenDisplay(null); }); // Update the buffered layer when the layer has a vertex removed entity.layer.on('pm:vertexremoved', (e) => { - if (e.layer._rings.length == 0) { + entity.pruneStalePolygonLayers(); + + if (!e.layer._rings?.length) { this.deleteAndRemoveEntity(this._selected, 'No vertex remaining, automatic deletion of entity'); return } entity.updateBufferedLayer(); this.refreshEntity(entity); //important that the buffer get updated before the rules are checked + this._updateCampSnapOutline(entity, this._isCampSnapOutlineExcluded(entity)); + this.UpdateOnScreenDisplay(entity); + }); + + // Update the snap outline when a new vertex gets added (prevents stale snap targets). + entity.layer.on('pm:vertexadded', () => { + entity.pruneStalePolygonLayers(); + entity.updateBufferedLayer(); + this.refreshEntity(entity); + this._updateCampSnapOutline(entity, this._isCampSnapOutlineExcluded(entity)); this.UpdateOnScreenDisplay(entity); }); @@ -426,6 +452,7 @@ export class Editor { } if (checkRules) this.refreshEntity(entity); + this._updateCampSnapOutline(entity, this._isCampSnapOutlineExcluded(entity)); } /** * Check rules, update area and update warning color. @@ -454,7 +481,7 @@ export class Editor { // console.log('entity pos changed'); entity.nameMarker.setLatLng(posEntity); } - if (entity.nameMarker._tooltip._content != entity.name && checkRules) { + if (entity.nameMarker._tooltip?._content != entity.name && checkRules) { // console.log('tooltip content changed', entity.nameMarker._tooltip); entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); } @@ -559,6 +586,7 @@ export class Editor { private removeEntity(entity: MapEntity, removeInRepository: boolean = true) { this.removeEntityNameTooltip(entity); this.removeEntityFromLayers(entity); + this._removeCampSnapOutline(entity.id); // Remove from current delete this._currentRevisions[entity.id]; @@ -582,6 +610,7 @@ export class Editor { this._placementLayers = new L.LayerGroup().addTo(map); this._placementBufferLayers = new L.LayerGroup().addTo(map); this._compareRevDiffLayer = new L.LayerGroup().addTo(map); + this._campSnapOutlines = new L.LayerGroup().addTo(map); //Place both in the same group so that we can toggle them on and off together on the map //@ts-ignore @@ -686,25 +715,62 @@ export class Editor { }); } + private _isCampSnapOutlineExcluded(entity: MapEntity): boolean { + return this._selected === entity && this._mode === 'editing-shape'; + } + + private _applyCampSnapOutlineDistances() { + const campSnapPx = metersToSnapPixels(this._map, CAMP_SNAP_DISTANCE_METERS); + for (const entityId in this._campSnapOutlineById) { + this._campSnapOutlineById[entityId].eachLayer((child) => { + child.options.snapDistance = campSnapPx; + L.PM.reInitLayer(child); + }); + } + } + + private _removeCampSnapOutline(entityId: number) { + const outline = this._campSnapOutlineById[entityId]; + if (!outline) { + return; + } + this._campSnapOutlines.removeLayer(outline); + delete this._campSnapOutlineById[entityId]; + } + + private _updateCampSnapOutline(entity: MapEntity, excluded: boolean) { + this._removeCampSnapOutline(entity.id); + if (excluded) { + return; + } + + entity.pruneStalePolygonLayers(); + const outline = createCampSnapOutlineLayer(entity.layer as L.GeoJSON, entity.id); + if (!outline) { + return; + } + + this._campSnapOutlineById[entity.id] = outline; + this._campSnapOutlines.addLayer(outline); + this._applyCampSnapOutlineDistances(); + } + private _syncPlacementSnapTargets(excludedEntity: MapEntity | null = null) { for (const entityId in this._currentRevisions) { const entity = this._currentRevisions[entityId]; - const snapIgnore = entity === excludedEntity; - if (entity.layer.options.snapIgnore === snapIgnore) { - continue; - } - entity.layer.options.snapIgnore = snapIgnore; - L.PM.reInitLayer(entity.layer); + this._updateCampSnapOutline(entity, entity === excludedEntity); } } private _initFireroadSnapLayers() { - const fireroad = this._groups['fireroad'] as L.GeoJSON | undefined; - if (!fireroad) { + const fireroadSnap = this._groups['fireroad_snap'] as L.GeoJSON | undefined; + if (!fireroadSnap) { return; } - fireroad.eachLayer((layer) => { + const roadSnapPx = metersToSnapPixels(this._map, SNAP_DISTANCE_METERS); + fireroadSnap.eachLayer((layer) => { + layer.options.snapDistance = roadSnapPx; L.PM.reInitLayer(layer); }); } @@ -802,16 +868,21 @@ export class Editor { this._map.pm.Toolbar.changeActionsOfControl('Polygon', ['cancel', 'removeLastVertex']); let writeDistanceOnTooltip = function (distance: number) { - // Update the tooltip content with the distance - let text = `Distance: ${distance.toFixed(1)} meters`; - document.querySelector('.leaflet-tooltip-bottom').innerText = text; + const tooltip = document.querySelector('.leaflet-tooltip-bottom'); + if (!tooltip) { + return; + } + tooltip.textContent = `Distance: ${distance.toFixed(1)} meters`; }; // This function is called when the user starts drawing a new polygon, it adds the distance to the tooltip this._map.on("pm:drawstart", ({ workingLayer }) => { // calculate the distance between the latest and previous vertices workingLayer.on("pm:vertexadded", (e) => { - let coords = e.workingLayer._latlngs; + let coords = getWorkingLayerLatLngs(e.workingLayer); + if (!coords) { + return; + } if (coords.length < 2) { return; } @@ -823,9 +894,15 @@ export class Editor { // calculate the distance between the latest vertex and the not yet placed vertex (mouse position) let snapdragFn = (e) => { - let coords = e.workingLayer._latlngs; + let coords = getWorkingLayerLatLngs(e.workingLayer); + if (!coords?.length) { + return; + } let prev = coords[coords.length - 1]; - let next = e.marker._latlng; + let next = e.snapLatLng ?? e.marker?._latlng; + if (prev?.lng == null || next?.lng == null) { + return; + } let distance = Turf.distance([prev.lng, prev.lat], [next.lng, next.lat], { units: 'meters' }); writeDistanceOnTooltip(distance); }; @@ -836,9 +913,15 @@ export class Editor { workingLayer.on("pm:snap", (e) => { // toggle off the snapdrag event workingLayer.off("pm:snapdrag", snapdragFn); - let coords = e.workingLayer._latlngs; + let coords = getWorkingLayerLatLngs(e.workingLayer); + if (!coords?.length) { + return; + } let prev = coords[coords.length - 1]; - let next = e.snapLatLng; + let next = e.snapLatLng ?? e.marker?._latlng; + if (prev?.lng == null || next?.lng == null) { + return; + } let distance = Turf.distance([prev.lng, prev.lat], [next.lng, next.lat], { units: 'meters' }); writeDistanceOnTooltip(distance); }); @@ -990,6 +1073,8 @@ export class Editor { }); this._updateSnapOptions(); + this._applyCampSnapOutlineDistances(); + this._initFireroadSnapLayers(); }.bind(this)); // Add the event handler for newly created layers diff --git a/src/entities/ClusterCache.ts b/src/entities/ClusterCache.ts index 4b0ca1d2..6f2f3a0b 100644 --- a/src/entities/ClusterCache.ts +++ b/src/entities/ClusterCache.ts @@ -3,9 +3,9 @@ export class ClusterCache { // Since moving or redrawing a entity creates a new leaflet_id we dont have to worry about invalidating cache results. private areaCache: { [key: number]: number; } = {}; private overlapCache: { [key: number]: { [key: number]: Boolean; }; } = {}; // a dict with a dict of booleans eg. x[1][2] = true - private coordsCache: { [key: string]: Array<{ [key: string]: number; }>; } = {}; - - public coordsHaveChanged(layerID: any, coords: Array<{ [key: string]: number; }>) { + private coordsCache: { [key: string]: Array<{ lat: number; lng: number }> } = {}; + + public coordsHaveChanged(layerID: number, coords: Array<{ lat: number; lng: number }>) { //Input: coords -> layer._latlng[0] // Returns true if coords are still the same for layerID and caches new coords if not // can be used to check if layerID should be cache invalidated diff --git a/src/entities/entity.ts b/src/entities/entity.ts index 9f06ec26..1ae540d6 100644 --- a/src/entities/entity.ts +++ b/src/entities/entity.ts @@ -5,6 +5,7 @@ import type { Rule } from '../rule'; import DOMPurify from 'dompurify'; import { EntityDTO, Appliance } from './interfaces'; import { LayerStyles, Colors, AreaTypesColor } from './enums'; +import type { PolygonFeature } from '../types/geojson'; /** * Represents the fields and data for single Map Entity and includes @@ -20,7 +21,7 @@ export class MapEntity implements EntityDTO { public readonly timeStamp: number; public readonly isDeleted: boolean; public readonly deleteReason: string; - public readonly layer: L.Layer & { pm?: any }; + public layer: L.GeoJSON; public bufferLayer: L.GeoJSON; public revisions: Record; public nameMarker: L.Marker; @@ -60,18 +61,44 @@ export class MapEntity implements EntityDTO { return JSON.stringify(this.toGeoJSON()); } + /** The polygon Leaflet layer Geoman actually edits inside the GeoJSON wrapper. */ + public getEditablePolygonLayer(): L.Polygon | undefined { + const layers = (this.layer as L.GeoJSON).getLayers() as L.Polygon[]; + return layers[layers.length - 1]; + } + + /** Geoman can leave extra polygons in the GeoJSON group; keep only the active one. */ + public pruneStalePolygonLayers() { + const group = this.layer as L.GeoJSON; + const layers = [...group.getLayers()] as L.Polygon[]; + if (layers.length <= 1) { + return; + } + + const keep = layers[layers.length - 1]; + for (const layer of layers) { + if (layer !== keep) { + group.removeLayer(layer); + } + } + } + /** Extracts the GeoJson from the internal Leaflet layer to make sure its up-to-date */ - private _calculateGeoJson() { - //@ts-ignore - let geoJson = this.layer.toGeoJSON(); + private _calculateGeoJson(): PolygonFeature { + const polygonLayer = this.getEditablePolygonLayer(); + if (polygonLayer) { + return polygonLayer.toGeoJSON() as PolygonFeature; + } + + let geoJson = this.layer.toGeoJSON() as PolygonFeature | GeoJSON.FeatureCollection; // Make sure that its a single features and not a collection, as Geoman // sometimes mess it up - if (geoJson.features && geoJson.features[0]) { - geoJson = geoJson.features[0]; + if ('features' in geoJson && geoJson.features?.[0]) { + geoJson = geoJson.features[0] as PolygonFeature; } - return geoJson; + return geoJson as PolygonFeature; } constructor(data: EntityDTO, rules: Array) { @@ -93,9 +120,14 @@ export class MapEntity implements EntityDTO { pmIgnore: false, interactive: true, bubblingMouseEvents: false, - snapIgnore: false, + snapIgnore: true, style: (/*feature*/) => this._getDefaultLayerStyle(), + onEachFeature: (_feature, layer) => { + layer.options.snapIgnore = true; + }, }); + //@ts-ignore + this.layer.options.entityId = this.id; this.revisions = {}; @@ -140,7 +172,7 @@ export class MapEntity implements EntityDTO { } public checkAllRules() { - // Check which rules are currently broken + this.pruneStalePolygonLayers(); for (const rule of this._rules) { rule.checkRule(this); } @@ -181,8 +213,12 @@ export class MapEntity implements EntityDTO { } public updateBufferedLayer() { - // Update the buffer layer so that its geometry is the same as this.layers geometry - const geoJson = this.layer.toGeoJSON(); + const polygonLayer = this.getEditablePolygonLayer(); + if (!polygonLayer) { + return; + } + + const geoJson = polygonLayer.toGeoJSON(); const buffered = Turf.buffer(geoJson, this._bufferWidth, { units: 'meters' }); const weight = this.getAllTriggeredRules().some((r) => r.shouldShowFireBuffer) ? 1 : 0; if (!this.bufferLayer) { @@ -207,7 +243,7 @@ export class MapEntity implements EntityDTO { } /** Converts a the current map entity data represented as GeoJSON */ - public toGeoJSON() { + public toGeoJSON(): PolygonFeature { // Get the up-to-date geo json data from the layer const geoJson = this._calculateGeoJson(); diff --git a/src/loaders/loadBaseLayers.ts b/src/loaders/loadBaseLayers.ts index f746857b..3c00815d 100644 --- a/src/loaders/loadBaseLayers.ts +++ b/src/loaders/loadBaseLayers.ts @@ -8,6 +8,7 @@ import { loadImageOverlay } from './loadImageOverlay'; import { addPowerGridTomap } from './_addPowerGrid'; import { addPointsOfInterestsTomap } from './_addPOI'; import * as Turf from '@turf/turf'; +import { FIREROAD_CLEARANCE_METERS, FIREROAD_SNAP_OUTSET_METERS } from '../../SETTINGS'; export const loadBaseLayers = async (map: any, _isCleanAndQuietMode?: boolean) => { // Add the Google Satellite layer if online, otherwise load the drawn map @@ -50,7 +51,22 @@ export const loadBaseLayers = async (map: any, _isCleanAndQuietMode?: boolean) = // Loads "fireroads" // with the fireroads as a reference, also load "publicplease" and "oktocamp" with a bigger buffer - await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 2.5, snapTarget: true }); + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: FIREROAD_CLEARANCE_METERS, + }); + // Snap to outline outside clearance so camps do not sit on the forbidden boundary + await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { + buffer: FIREROAD_CLEARANCE_METERS + FIREROAD_SNAP_OUTSET_METERS, + propertyRenameFn: () => 'fireroad_snap', + snapTarget: true, + styleFn: () => ({ + color: '#000000', + weight: 0, + opacity: 0, + fillOpacity: 0, + }), + }); + map.groups.fireroad_snap.addTo(map); await loadGeoJsonFeatureCollections(map, 'type', './data/bl26/fireroads.geojson', { buffer: 3.5, propertyRenameFn: () => 'publicplease', diff --git a/src/rule/index.ts b/src/rule/index.ts index ecd1bf5d..f2eb0be6 100644 --- a/src/rule/index.ts +++ b/src/rule/index.ts @@ -98,6 +98,8 @@ export function generateRulesForEditor(groups: any, placementLayers: any): () => Severity.High, 'Touching fireroad!', 'Plz move this area away from the fire road!', + undefined, + { clearanceZone: true }, ), Rules.isNotInsideBoundaries( groups.propertyborder, @@ -166,25 +168,24 @@ export function generateRulesForEditor(groups: any, placementLayers: any): () => // Function not used? /** Utility function to calculate the ovelap between a geojson and layergroup */ function _isGeoJsonOverlappingLayergroup( - geoJson: Turf.helpers.Feature | Turf.helpers.Geometry, + geoJson: GeoJSON.Feature | GeoJSON.Geometry, layerGroup: L.GeoJSON, ): boolean { //NOTE: Only checks overlaps, not if its inside or covers completely let overlap = false; layerGroup.eachLayer((layer) => { - //@ts-ignore - let otherGeoJson = layer.toGeoJSON(); + const otherGeoJson = layer.toGeoJSON() as GeoJSON.Feature | GeoJSON.FeatureCollection; //Loop through all features if it is a feature collection - if (otherGeoJson.features) { + if ('features' in otherGeoJson && otherGeoJson.features) { for (let i = 0; i < otherGeoJson.features.length; i++) { if (Turf.booleanOverlap(geoJson, otherGeoJson.features[i])) { overlap = true; return; // Break out of the inner loop } } - } else if (Turf.booleanOverlap(geoJson, otherGeoJson)) { + } else if (Turf.booleanOverlap(geoJson, otherGeoJson as GeoJSON.Feature)) { overlap = true; } diff --git a/src/rule/rules/isBufferOverlappingRecursive.ts b/src/rule/rules/isBufferOverlappingRecursive.ts index 6e50a7fa..46e15b81 100644 --- a/src/rule/rules/isBufferOverlappingRecursive.ts +++ b/src/rule/rules/isBufferOverlappingRecursive.ts @@ -5,7 +5,15 @@ import { FIRE_BUFFER_IN_METER } from '../../../SETTINGS'; import { Severity, Rule, clusterCache, ruler } from '../index'; -import { compareLayers, getBBoxForCoords, fastIsOverlap } from './utils'; +import { + compareLayers, + getBBoxForCoords, + fastIsOverlap, + getActivePolygonFeatureFromLayer, + hasSignificantPolygonOverlap, +} from './utils'; +import type { PolygonFeature } from '../../types/geojson'; +import { getGeoJsonChildLayer, getLayerLatLngRing } from '../../types/leafletHelpers'; const CHEAP_RULER_BUFFER: number = FIRE_BUFFER_IN_METER + 1; // We add a little extra to the buffer, to compensate for usign the approximation method from cheapruler @@ -15,16 +23,31 @@ export const isBufferOverlappingRecursive = ( shortMsg: string, message: string ) => new Rule(severity, shortMsg, message, (entity) => { - //@ts-ignore - const layer = entity.layer._layers[Object.keys(entity.layer._layers)[0]]; + const layer = getGeoJsonChildLayer(entity.layer); + if (!layer) { + return { triggered: false }; + } // invalidate cache if coords have changed - //@ts-ignore - clusterCache.coordsHaveChanged(layer._leaflet_id, layer._latlngs[0]) && + const ring = getLayerLatLngRing(layer); + if ( + layer._leaflet_id != null && + ring && + clusterCache.coordsHaveChanged( + layer._leaflet_id, + ring.map((ll) => ({ lat: ll.lat, lng: ll.lng })), + ) && + entity.layer._leaflet_id != null + ) { clusterCache.invalidateCache(entity.layer._leaflet_id); + } const checkedOverlappingLayers = new Set(); - let totalArea = _getTotalAreaOfOverlappingEntities(entity.layer, layerGroup, checkedOverlappingLayers); + let totalArea = _getTotalAreaOfOverlappingEntities( + entity.layer, + layerGroup, + checkedOverlappingLayers, + ); if (totalArea > MAX_CLUSTER_SIZE) { return { triggered: true, @@ -49,27 +72,24 @@ function _getTotalAreaOfOverlappingEntities( checkedOverlappingLayers.add(layer._leaflet_id); } + const layerFeature = getActivePolygonFeatureFromLayer(layer); + if (!layerFeature) { + return 0; + } + let totalArea: number; //@ts-ignore if (clusterCache.areaIsCached(layer._leaflet_id)) { //@ts-ignore totalArea = clusterCache.getAreaCache(layer._leaflet_id); } else { - //@ts-ignore - totalArea = Turf.area(layer.toGeoJSON()); + totalArea = Turf.area(layerFeature); //@ts-ignore clusterCache.setAreaCache(layer._leaflet_id, totalArea); } // get an approximate bounding box with firebuffer padding to use for later calculations - //@ts-ignore - /* you can get bounds like so: - const bounds = layer.getBounds() - let boxBounds = [bounds._southWest.lng,bounds._southWest.lat,bounds._northEast.lng,bounds._northEast.lat] - However, layer.getBounds is not updated when moving the layer. Need to call layer.geoJson for an udpated bounds. - */ - //@ts-ignore - let boxBounds = getBBoxForCoords(layer.toGeoJSON().features[0].geometry.coordinates[0]); + let boxBounds = getBBoxForCoords(layerFeature.geometry.coordinates[0]); //@ts-ignore const bBox = ruler.bufferBBox(boxBounds, CHEAP_RULER_BUFFER); // add buffer padding to box @@ -103,28 +123,14 @@ function _getTotalAreaOfOverlappingEntities( //@ts-ignore clusterCache.setOverlapCache(layer._leaflet_id, otherLayer._leaflet_id, overlaps); } else { - // bounding boxes overlap so polygons might overlap. Time to do the expensive calculations - //@ts-ignore - const otherLayerGeoJSON = otherLayer.toGeoJSON(); - let otherLayerPolygon; - if (otherLayerGeoJSON.type === 'Feature') { - otherLayerPolygon = otherLayerGeoJSON.geometry; - } else if (otherLayerGeoJSON.type === 'FeatureCollection') { - otherLayerPolygon = otherLayerGeoJSON.features[0]; - } else { - // Unsupported geometry type - throw new Error('unsupported geometry'); - } - - //@ts-ignore - let buffer = Turf.buffer(layer.toGeoJSON(), FIRE_BUFFER_IN_METER, { - units: 'meters', - }) as Turf.helpers.FeatureCollection; - if (Turf.booleanOverlap(buffer.features[0], otherLayerPolygon) || - Turf.booleanContains(buffer.features[0], otherLayerPolygon)) { - overlaps = true; - } else { + const otherFeature = getActivePolygonFeatureFromLayer(otherLayer); + if (!otherFeature) { overlaps = false; + } else { + const buffer = Turf.buffer(layerFeature, FIRE_BUFFER_IN_METER, { + units: 'meters', + }) as PolygonFeature; + overlaps = hasSignificantPolygonOverlap(buffer, otherFeature); } //@ts-ignore clusterCache.setOverlapCache(layer._leaflet_id, otherLayer._leaflet_id, overlaps); diff --git a/src/rule/rules/isOverlapping.ts b/src/rule/rules/isOverlapping.ts index 10ac51c1..26e82b19 100644 --- a/src/rule/rules/isOverlapping.ts +++ b/src/rule/rules/isOverlapping.ts @@ -1,45 +1,74 @@ -import * as Turf from '@turf/turf'; import * as L from 'leaflet'; import { Severity, Rule } from '../index'; -import { compareLayers, getBBoxForCoords, fastIsOverlap } from './utils'; +import { + compareLayers, + getBBoxForCoords, + fastIsOverlap, + getActivePolygonFeatureFromLayer, + campsShareForbiddenAreaOverlap, + getPolygonFeatureFromGeoJson, +} from './utils'; +import { MapEntity } from '../../entities'; +import type { PolygonFeature } from '../../types/geojson'; export const isOverlapping = ( - layerGroup: any, - severity: Severity, - shortMsg: string, + layerGroup: any, + severity: Severity, + shortMsg: string, message: string ) => new Rule(severity, shortMsg, message, (entity) => { - return { triggered: _isLayerOverlappingOrContained(entity.layer, layerGroup) }; + return { triggered: _campsOverlap(entity, layerGroup) }; }); -/** Utility function to calculate the ovelap between a layer and layergroup */ -function _isLayerOverlappingOrContained(layer: L.Layer, layerGroup: L.GeoJSON): boolean { - //NOTE: Only checks overlaps, not if its inside or covers completely - //@ts-ignore - let layerGeoJson = layer.toGeoJSON(); - let bBox = getBBoxForCoords(layerGeoJson.features[0].geometry.coordinates[0]); - //@ts-ignore +function getCampPolygonForRules(entity: MapEntity): PolygonFeature | null { + entity.pruneStalePolygonLayers(); + const geoJson = entity.toGeoJSON(); + if (geoJson.geometry.type === 'Polygon') { + return geoJson; + } + return getPolygonFeatureFromGeoJson(geoJson); +} + +/** True if this camp shares area with another camp (touching without overlapping is OK). */ +function _campsOverlap(entity: MapEntity, layerGroup: L.GeoJSON): boolean { + const campFeature = getCampPolygonForRules(entity); + if (!campFeature) { + return false; + } + + const bBox = getBBoxForCoords(campFeature.geometry.coordinates[0]); let overlap = false; - let i = 0; + layerGroup.eachLayer((otherLayer) => { if (overlap) { return; } - if (compareLayers(layer, otherLayer)) { + if (compareLayers(entity.layer, otherLayer)) { return; } - //@ts-ignore - let otherGeoJson = otherLayer.toGeoJSON(); - //@ts-ignore - let otherBBox = getBBoxForCoords(otherGeoJson.features[0].geometry.coordinates[0]); - if (fastIsOverlap(bBox, otherBBox)) { - // Might overlap - if (Turf.booleanOverlap(layerGeoJson.features[0], otherGeoJson.features[0]) || - Turf.booleanContains(layerGeoJson.features[0], otherGeoJson.features[0])) { - overlap = true; - } + const otherEntityId = otherLayer.options?.entityId; + // Ignore Geoman draw/temp layers β€” only compare saved camps + if (otherEntityId == null) { + return; + } + if (otherEntityId === entity.id) { + return; + } + + const otherFeature = getActivePolygonFeatureFromLayer(otherLayer); + if (!otherFeature) { + return; + } + + const otherBBox = getBBoxForCoords(otherFeature.geometry.coordinates[0]); + if (!fastIsOverlap(bBox, otherBBox)) { + return; + } + + if (campsShareForbiddenAreaOverlap(campFeature, otherFeature)) { + overlap = true; } }); + return overlap; } - diff --git a/src/rule/rules/isOverlappingOrContained.ts b/src/rule/rules/isOverlappingOrContained.ts index 06619608..cf0eaba7 100644 --- a/src/rule/rules/isOverlappingOrContained.ts +++ b/src/rule/rules/isOverlappingOrContained.ts @@ -1,43 +1,50 @@ import * as Turf from '@turf/turf'; import { Severity, Rule } from '../index'; import { MapEntity } from '../../entities'; +import { + getPolygonFeatureFromLayer, + hasSignificantPolygonOverlap, + campOverlapsClearanceZone, +} from './utils'; export const isOverlappingOrContained = ( - layerGroup: any, - severity: Severity, - shortMsg: string, + layerGroup: any, + severity: Severity, + shortMsg: string, message: string, - skipFor: (entity: MapEntity) => boolean = () => false -) => new Rule(severity, shortMsg, message, (entity) => { - if (skipFor(entity)) { - return { triggered: false }; - } - let geoJson = entity.toGeoJSON(); - let overlap = false; + skipFor: (entity: MapEntity) => boolean = () => false, + options: { clearanceZone?: boolean } = {}, +) => + new Rule(severity, shortMsg, message, (entity) => { + if (skipFor(entity)) { + return { triggered: false }; + } + + const campFeature = entity.toGeoJSON(); + if (!campFeature?.geometry || campFeature.geometry.type !== 'Polygon') { + return { triggered: false }; + } + + let overlap = false; + + layerGroup?.eachLayer((layer) => { + if (overlap) { + return; + } - // added "?" incase there is no layer for the rule that has been added. - // e.g. no publicplease layer, but the rule is still there - layerGroup?.eachLayer((layer) => { - //@ts-ignore - let otherGeoJson = layer.toGeoJSON(); - - //Loop through all features if it is a feature collection - if (otherGeoJson.features) { - for (let i = 0; i < otherGeoJson.features.length; i++) { - if (Turf.booleanOverlap(geoJson, otherGeoJson.features[i]) || - Turf.booleanContains(otherGeoJson.features[i], geoJson)) { + const zoneFeature = getPolygonFeatureFromLayer(layer); + if (!zoneFeature) { + return; + } + + if (options.clearanceZone) { + if (campOverlapsClearanceZone(campFeature, zoneFeature)) { overlap = true; - return; // Break out of the inner loop } + } else if (hasSignificantPolygonOverlap(campFeature, zoneFeature)) { + overlap = true; } - } else if (Turf.booleanOverlap(geoJson, otherGeoJson) || Turf.booleanContains(otherGeoJson, geoJson)) { - overlap = true; - } + }); - if (overlap) { - return; // Break out of the loop once an overlap is found - } + return { triggered: overlap }; }); - - return { triggered: overlap }; -}); diff --git a/src/rule/rules/utils.ts b/src/rule/rules/utils.ts index 950c9a1a..d0866b70 100644 --- a/src/rule/rules/utils.ts +++ b/src/rule/rules/utils.ts @@ -1,7 +1,18 @@ import * as L from 'leaflet'; +import * as Turf from '@turf/turf'; +import { + MIN_CAMP_AREA_OVERLAP_SQM, + MIN_CAMP_OVERLAP_AREA_SQM, + SNAP_EDGE_TOLERANCE_METERS, +} from '../../../SETTINGS'; +import type { + Feature, + FeatureCollection, + GeoJsonFeatureInput, + PolygonFeature, +} from '../../types/geojson'; export function compareLayers(layer1: L.Layer, layer2: L.Layer): boolean { - //@ts-ignore return layer1._leaflet_id === layer2._leaflet_id; } @@ -41,4 +52,200 @@ export function fastIsOverlap(layerBBox: Array, otherBBox: Array return false; } return true; -} \ No newline at end of file +} + +/** First valid polygon feature from a Leaflet layer or GeoJSON group. */ +export function getPolygonFeatureFromLayer(layer: L.Layer): PolygonFeature | null { + return getPolygonFeatureFromGeoJson(layer.toGeoJSON?.()); +} + +/** Polygon Geoman is editing β€” last child layer; drops stale copies in the group. */ +export function getActivePolygonFeatureFromLayer(layer: L.Layer): PolygonFeature | null { + const group = layer as L.GeoJSON; + const childLayers = group.getLayers?.(); + if (childLayers && childLayers.length > 1) { + const keep = childLayers[childLayers.length - 1] as L.Layer; + for (let i = 0; i < childLayers.length - 1; i++) { + group.removeLayer(childLayers[i]); + } + return getPolygonFeatureFromLayer(keep); + } + if (childLayers?.length === 1) { + return getPolygonFeatureFromLayer(childLayers[0] as L.Layer); + } + + const gj = layer.toGeoJSON?.(); + return getPolygonFeatureFromGeoJson(gj, true); +} + +export function getPolygonFeatureFromGeoJson( + gj: GeoJsonFeatureInput | GeoJSON.GeoJsonObject, + preferLast = false, +): PolygonFeature | null { + if (!gj || typeof gj !== 'object' || !('type' in gj)) { + return null; + } + + let feature: Feature | undefined; + if (gj.type === 'Feature') { + feature = gj as Feature; + } else if (gj.type === 'FeatureCollection' && (gj as FeatureCollection).features?.length) { + const collection = gj as FeatureCollection; + const polygons = collection.features.filter( + (f) => f.geometry?.type === 'Polygon' && f.geometry.coordinates?.[0]?.length, + ); + if (polygons.length) { + feature = preferLast ? polygons[polygons.length - 1] : polygons[0]; + } else { + feature = preferLast + ? collection.features[collection.features.length - 1] + : collection.features[0]; + } + } + + if (!feature?.geometry || feature.geometry.type !== 'Polygon') { + return null; + } + + const ring = feature.geometry.coordinates?.[0]; + if (!ring?.length || ring.length < 4) { + return null; + } + + return feature as PolygonFeature; +} + +function shrinkPolygonForOverlapTest( + feature: PolygonFeature, + insetMeters: number, +): PolygonFeature | null { + try { + const shrunk = Turf.buffer(feature, -insetMeters, { units: 'meters' }); + if (!shrunk?.geometry || shrunk.geometry.type !== 'Polygon') { + return null; + } + if (Turf.area(shrunk) < 0.1) { + return null; + } + return shrunk as PolygonFeature; + } catch { + return null; + } +} + +/** True when two polygons share interior overlap, not just a snapped shared edge. */ +export function hasSignificantPolygonOverlap( + a: PolygonFeature, + b: PolygonFeature, + minAreaSqMeters: number = MIN_CAMP_OVERLAP_AREA_SQM, + edgeToleranceMeters: number = SNAP_EDGE_TOLERANCE_METERS, +): boolean { + const shrunkA = shrinkPolygonForOverlapTest(a, edgeToleranceMeters); + const shrunkB = shrinkPolygonForOverlapTest(b, edgeToleranceMeters); + + if (shrunkA && shrunkB) { + try { + const intersection = Turf.intersect(Turf.featureCollection([shrunkA, shrunkB])); + if (intersection) { + return Turf.area(intersection) > minAreaSqMeters; + } + return false; + } catch { + return false; + } + } + + // Too small to shrink β€” only flag clear containment, not edge contact + if (Turf.booleanContains(a, b) || Turf.booleanContains(b, a)) { + try { + const smaller = Turf.area(a) <= Turf.area(b) ? a : b; + const larger = smaller === a ? b : a; + return Turf.area(smaller) > minAreaSqMeters && Turf.booleanContains(larger, smaller); + } catch { + return false; + } + } + + return false; +} + +/** Camp overlaps a clearance/restriction zone (fireroad, slope, etc.), not merely touching its edge. */ +export function campOverlapsClearanceZone( + camp: PolygonFeature, + zone: PolygonFeature, + zoneInsetMeters: number = SNAP_EDGE_TOLERANCE_METERS, +): boolean { + const insetZone = shrinkPolygonForOverlapTest(zone, zoneInsetMeters); + if (!insetZone) { + return false; + } + return hasSignificantPolygonOverlap(camp, insetZone, MIN_CAMP_OVERLAP_AREA_SQM, 0); +} + +/** + * Camp-vs-camp: true if polygons share any area (not allowed). + * Adjacent camps that only touch along an edge/vertex are OK (β‰ˆ0 mΒ² intersection). + */ +function intersectionHasPolygonArea( + intersection: Feature | FeatureCollection | null, + minAreaSqMeters: number, +): boolean { + if (!intersection) { + return false; + } + + if (intersection.type === 'FeatureCollection') { + return intersection.features.some( + (f) => f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon', + ) && Turf.area(intersection) > minAreaSqMeters; + } + + if (!intersection.geometry) { + return false; + } + + const { type } = intersection.geometry; + if (type === 'Polygon' || type === 'MultiPolygon') { + return Turf.area(intersection) > minAreaSqMeters; + } + + return false; +} + +export function campsShareForbiddenAreaOverlap( + a: PolygonFeature, + b: PolygonFeature, + minOverlapAreaSqMeters: number = MIN_CAMP_AREA_OVERLAP_SQM, +): boolean { + try { + if (Turf.booleanDisjoint(a, b)) { + return false; + } + } catch { + // continue with intersection test + } + + try { + const intersection = Turf.intersect(Turf.featureCollection([a, b])); + if (intersectionHasPolygonArea(intersection, minOverlapAreaSqMeters)) { + return true; + } + } catch { + // disjoint or edge-only contact + } + + if (Turf.booleanContains(a, b) || Turf.booleanContains(b, a)) { + try { + const smaller = Turf.area(a) <= Turf.area(b) ? a : b; + const larger = smaller === a ? b : a; + return ( + Turf.area(smaller) > minOverlapAreaSqMeters && + Turf.booleanContains(larger, smaller) + ); + } catch { + return false; + } + } + + return false; +} diff --git a/src/types/geojson.ts b/src/types/geojson.ts new file mode 100644 index 00000000..90b86d2a --- /dev/null +++ b/src/types/geojson.ts @@ -0,0 +1,8 @@ +import type { Feature, FeatureCollection, Geometry, Polygon } from 'geojson'; + +/** GeoJSON Feature with Polygon geometry (camps, zones, buffers). */ +export type PolygonFeature = Feature; + +export type GeoJsonFeatureInput = Feature | FeatureCollection | undefined; + +export type { Feature, FeatureCollection, Geometry, Polygon }; diff --git a/src/types/leaflet-geoman.d.ts b/src/types/leaflet-geoman.d.ts new file mode 100644 index 00000000..f39a8cb1 --- /dev/null +++ b/src/types/leaflet-geoman.d.ts @@ -0,0 +1,38 @@ +import 'leaflet'; + +/** Leaflet / Geoman internals and editor-only options used across the map. */ +declare module 'leaflet' { + interface LayerOptions { + snapIgnore?: boolean; + snapDistance?: number; + entityId?: number; + isCampSnapOutline?: boolean; + } + + interface Layer { + _leaflet_id?: number; + _rings?: unknown[]; + /** Polygon: ring array; polyline: flat LatLng list. */ + _latlngs?: LatLng[] | LatLng[][]; + toGeoJSON?: () => GeoJSON.GeoJsonObject; + } + + interface GeoJSON { + _layers?: Record; + _leaflet_id?: number; + pm?: { enable(options?: object): void; disable(): void }; + } + + interface Marker { + _latlng?: LatLng; + _tooltip?: Tooltip & { _content?: string }; + } + + interface Path { + dragging?: { enable(): void; disable(): void }; + } + + interface Polygon { + dragging?: { enable(): void; disable(): void }; + } +} diff --git a/src/types/leafletHelpers.ts b/src/types/leafletHelpers.ts new file mode 100644 index 00000000..73f3f006 --- /dev/null +++ b/src/types/leafletHelpers.ts @@ -0,0 +1,36 @@ +import * as L from 'leaflet'; + +/** GeoJSON group with Leaflet’s internal child-layer map (used for drag / edit). */ +export type GeoJsonLayerGroup = L.GeoJSON & { + _layers: Record; + _leaflet_id: number; +}; + +/** Child polygon layer Geoman edits inside a camp GeoJSON group. */ +export function getGeoJsonChildLayer(group: L.GeoJSON): L.Path | undefined { + const g = group as GeoJsonLayerGroup; + if (!g._layers || g._leaflet_id == null) { + return undefined; + } + return g._layers[g._leaflet_id - 1] as L.Path | undefined; +} + +/** Outer ring coords for cluster-cache invalidation (`layer._latlngs[0]` on polygons). */ +export function getLayerLatLngRing(layer: L.Layer): L.LatLng[] | undefined { + const latlngs = layer._latlngs; + if (!latlngs?.length) { + return undefined; + } + const first = latlngs[0]; + if (first && typeof first === 'object' && 'lat' in first) { + return latlngs as L.LatLng[]; + } + return latlngs[0] as L.LatLng[]; +} + +/** Geoman draw/edit working layer with vertex list. */ +export type PmWorkingLayer = L.Layer & { _latlngs?: L.LatLng[] }; + +export function getWorkingLayerLatLngs(workingLayer: L.Layer): L.LatLng[] | undefined { + return (workingLayer as PmWorkingLayer)._latlngs; +} diff --git a/src/utils/campSnapOutline.ts b/src/utils/campSnapOutline.ts new file mode 100644 index 00000000..28fbc35e --- /dev/null +++ b/src/utils/campSnapOutline.ts @@ -0,0 +1,39 @@ +import * as L from 'leaflet'; +import * as Turf from '@turf/turf'; +import { CAMP_SNAP_GAP_METERS } from '../../SETTINGS'; +import { getActivePolygonFeatureFromLayer } from '../rule/rules/utils'; + +const invisibleSnapStyle: L.PathOptions = { + color: '#000000', + weight: 0, + opacity: 0, + fillOpacity: 0, +}; + +/** Invisible outline just outside a camp β€” Geoman snaps here instead of the camp fill. */ +export function createCampSnapOutlineLayer( + entityLayer: L.GeoJSON, + entityId: number, + gapMeters: number = CAMP_SNAP_GAP_METERS, +): L.GeoJSON | null { + const campFeature = getActivePolygonFeatureFromLayer(entityLayer); + if (!campFeature) { + return null; + } + + const outline = Turf.buffer(campFeature, gapMeters, { units: 'meters' }); + if (!outline?.geometry) { + return null; + } + + const layer = L.geoJSON(outline, { + pmIgnore: false, + interactive: false, + snapIgnore: false, + style: () => invisibleSnapStyle, + }); + layer.options.entityId = entityId; + layer.options.isCampSnapOutline = true; + + return layer; +} diff --git a/tsconfig.json b/tsconfig.json index da91512c..6717b0f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,6 @@ "moduleResolution": "nodenext", "target": "es2020", "allowSyntheticDefaultImports": true - } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] } From 78e68f0cb3eecedf78e4bd9f254776dd03d190f9 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 27 May 2026 08:44:36 +0200 Subject: [PATCH 09/10] Merge conflict --- src/editor/editor.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index b9ed893f..2705aded 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -462,12 +462,22 @@ export class Editor { return; } - entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); + this.refreshEntityTooltip(entity, true); entity.checkAllRules(); let hideWarnings = this._hideWarningColors || this._isCleanAndQuietMode; entity.setLayerStyle('severity', hideWarnings); } + private refreshEntityTooltip(entity: MapEntity, checkRules: boolean = true) { + if (entity.nameMarker._tooltip?._content != entity.name && checkRules) { + entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); + } + + const zoom = this._map.getZoom(); + const nameTooltip = this._nameTooltips[entity.id]?._tooltip; + nameTooltip?.setOpacity(zoom >= 19 ? 1 : 0); + } + private refreshEntity(entity: MapEntity, checkRules: boolean = true) { if (entity == null) { return; @@ -481,20 +491,7 @@ export class Editor { // console.log('entity pos changed'); entity.nameMarker.setLatLng(posEntity); } - if (entity.nameMarker._tooltip?._content != entity.name && checkRules) { - // console.log('tooltip content changed', entity.nameMarker._tooltip); - entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); - } - - // Only show the name if zoomed beyond 19 - var zoom = this._map.getZoom(); - if (zoom >= 19) { - //@ts-ignore - this._nameTooltips[entity.id]._tooltip.setOpacity(1); - } else { - //@ts-ignore - this._nameTooltips[entity.id]._tooltip.setOpacity(0); - } + this.refreshEntityTooltip(entity, checkRules); if (checkRules) { entity.checkAllRules(); From 1f851dae996b525abffebde4f209df0456b133e8 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 27 May 2026 08:56:16 +0200 Subject: [PATCH 10/10] Resolved bug where info text sticks after deletion --- SETTINGS.ts | 3 +- src/editor/editor.ts | 72 ++++++++++++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/SETTINGS.ts b/SETTINGS.ts index b9d10008..3ab44843 100644 --- a/SETTINGS.ts +++ b/SETTINGS.ts @@ -3,8 +3,7 @@ */ // Repository -//export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; -export const REPOSITORY_URL: string = 'https://bl-map-dev.runasp.net'; +export const REPOSITORY_URL: string = 'https://robnowa.runasp.net'; // Rules export const MAX_CLUSTER_SIZE: number = 1250; diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 28c426fd..2b2e2133 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -469,14 +469,18 @@ export class Editor { return; } - this.refreshEntityTooltip(entity); + this.refreshEntityTooltip(entity, false); entity.checkAllRules(); let hideWarnings = this._hideWarningColors || this._isCleanAndQuietMode; entity.setLayerStyle('severity', hideWarnings); } - private refreshEntityTooltip(entity: MapEntity, checkRules: boolean = true) { - if (entity.nameMarker._tooltip?._content != entity.name && checkRules) { + private refreshEntityTooltip(entity: MapEntity | null, checkRules: boolean = true) { + if (!entity?.nameMarker) { + return; + } + + if (!checkRules || entity.nameMarker._tooltip?._content != entity.name) { entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); } @@ -498,7 +502,7 @@ export class Editor { // console.log('entity pos changed'); entity.nameMarker.setLatLng(posEntity); } - this.refreshEntityTooltip(entity); + this.refreshEntityTooltip(entity, checkRules); if (checkRules) { entity.checkAllRules(); @@ -522,14 +526,6 @@ export class Editor { } } - private refreshEntityTooltip(entity: MapEntity | null) { - if (!entity || !entity.nameMarker) { - return; - } - - entity.nameMarker.setTooltipContent(this.buildTooltipName(entity)); - } - private checkEntityRules(entitysToRefresh: Array | null = null) { Messages.showNotification('Validating, hold on...', undefined, undefined, 7000); if (entitysToRefresh) { @@ -581,6 +577,7 @@ export class Editor { private deleteAndRemoveEntity(entity: MapEntity, deleteReason: string = null) { this._selected = null; this.removeEntity(entity); + this.UpdateOnScreenDisplay(null); // Avoid setMode('none') here β€” it runs _syncPlacementSnapTargets / L.PM.reInitLayer // on camps still on the map, which can leave a ghost polygon after delete. this._mode = 'none'; @@ -678,8 +675,6 @@ export class Editor { className: 'shape-tooltip', }); this.campWarningTooltip.setLatLng([0, 0]); - this.campWarningTooltip.addTo(this._map); - this.campWarningTooltip.closeTooltip(); this._nameTooltips = {}; this.stopwatch = 0; @@ -854,7 +849,7 @@ export class Editor { // Refresh tooltips for all entities, because edit mode changes the tooltip text. for (const entityId in this._currentRevisions) { - this.refreshEntityTooltip(this._currentRevisions[entityId]); + this.refreshEntityTooltip(this._currentRevisions[entityId], false); } // Show instructions when entering edit mode, and wait for the user @@ -1053,6 +1048,35 @@ export class Editor { this._mapControls.forEach(control => this._map.removeControl(control)); } + /** Hides the rule-warning label (openOn/closeTooltip alone can leave DOM behind). */ + private hideCampWarningTooltip() { + this.campWarningTooltip.setContent(''); + if (this._map.hasLayer(this.campWarningTooltip)) { + this._map.removeLayer(this.campWarningTooltip); + } + const el = this.campWarningTooltip.getElement?.(); + if (el) { + el.style.display = 'none'; + el.innerHTML = ''; + } + // Orphaned copies can remain in the tooltip pane after openOn() + this._map.getPane('tooltipPane')?.querySelectorAll('.shape-tooltip').forEach((node) => { + node.remove(); + }); + } + + private showCampWarningTooltip(latLng: L.LatLng, tooltipText: string) { + this.campWarningTooltip.setContent(tooltipText); + this.campWarningTooltip.setLatLng(latLng); + if (!this._map.hasLayer(this.campWarningTooltip)) { + this.campWarningTooltip.addTo(this._map); + } + const el = this.campWarningTooltip.getElement?.(); + if (el) { + el.style.display = ''; + } + } + private UpdateOnScreenDisplay(entity: MapEntity | null, customMsg: string = null) { if (entity || customMsg) { let tooltipText = ''; @@ -1067,11 +1091,21 @@ export class Editor { } } - this.campWarningTooltip.openOn(this._map); - this.campWarningTooltip.setLatLng(entity.layer.getBounds().getCenter()); - this.campWarningTooltip.setContent(tooltipText); + if (!tooltipText) { + this.hideCampWarningTooltip(); + return; + } + + const latLng = customMsg + ? entity?.layer.getBounds().getCenter() + : entity.layer.getBounds().getCenter(); + if (!latLng) { + this.hideCampWarningTooltip(); + return; + } + this.showCampWarningTooltip(latLng, tooltipText); } else { - this.campWarningTooltip.close(); + this.hideCampWarningTooltip(); } }