diff --git a/SETTINGS.ts b/SETTINGS.ts
index cd7e8a23..3ab44843 100644
--- a/SETTINGS.ts
+++ b/SETTINGS.ts
@@ -10,10 +10,29 @@ 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';
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';
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 Tools
- Click the "Edit" button in the lower left
- - Start drawing a shape and close it by clicking on the first point
+ -
+ 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.
+
-
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!)
diff --git a/src/editor/editor.ts b/src/editor/editor.ts
index bdf941ec..2b2e2133 100644
--- a/src/editor/editor.ts
+++ b/src/editor/editor.ts
@@ -11,8 +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
@@ -46,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;
@@ -102,6 +111,7 @@ export class Editor {
// Deselect and stop editing
if (this._mode == 'none') {
+ this._syncPlacementSnapTargets(null);
this.setSelected(null, prevEntity);
this.setPopup('none');
return;
@@ -109,34 +119,37 @@ export class Editor {
// Select an entity for editing
if (this._mode == 'selected' && nextEntity) {
+ this._syncPlacementSnapTargets(null);
this.setSelected(nextEntity, prevEntity);
this.setPopup('info', nextEntity);
// 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;
}
// Edit the shape of the entity
if (this._mode == 'editing-shape' && nextEntity) {
+ this._syncPlacementSnapTargets(nextEntity);
nextEntity.layer.pm.enable({
editMode: true,
- snappable: false,
allowSelfIntersection: false,
- });
+ ...this._getShapeEditSnapOptions(),
+ } as Parameters['enable']>[0]);
this.setSelected(nextEntity);
this.setPopup('none');
return;
}
// Move the shape of the entity
if (this._mode == 'moving-shape' && nextEntity) {
+ this._syncPlacementSnapTargets(null);
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
@@ -328,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();
@@ -396,22 +414,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);
});
@@ -428,6 +459,7 @@ export class Editor {
}
if (checkRules) this.refreshEntity(entity);
+ this._updateCampSnapOutline(entity, this._isCampSnapOutlineExcluded(entity));
}
/**
* Check rules, update area and update warning color.
@@ -437,12 +469,26 @@ 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 | null, checkRules: boolean = true) {
+ if (!entity?.nameMarker) {
+ return;
+ }
+
+ if (!checkRules || entity.nameMarker._tooltip?._content != entity.name) {
+ 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;
@@ -456,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();
@@ -480,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) {
@@ -537,9 +575,13 @@ 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);
+ 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';
+ this.setPopup('none');
this._repository.deleteEntity(entity, deleteReason);
}
@@ -562,6 +604,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];
@@ -585,6 +628,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
@@ -615,28 +659,12 @@ 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();
+ this._initFireroadSnapLayers();
// 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);
@@ -647,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;
@@ -669,6 +695,102 @@ export class Editor {
}
}
+ 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,
+ };
+ }
+
+ 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(),
+ });
+ }
+
+ 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];
+ this._updateCampSnapOutline(entity, entity === excludedEntity);
+ }
+ }
+
+ private _initFireroadSnapLayers() {
+ const fireroadSnap = this._groups['fireroad_snap'] as L.GeoJSON | undefined;
+ if (!fireroadSnap) {
+ return;
+ }
+
+ const roadSnapPx = metersToSnapPixels(this._map, SNAP_DISTANCE_METERS);
+ fireroadSnap.eachLayer((layer) => {
+ layer.options.snapDistance = roadSnapPx;
+ L.PM.reInitLayer(layer);
+ });
+ }
+
public hideWarningColors(hide: boolean = true) {
this._hideWarningColors = hide;
for (const entityid in this._currentRevisions) {
@@ -727,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
@@ -760,24 +882,28 @@ 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']);
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;
}
@@ -789,9 +915,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);
};
@@ -802,9 +934,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);
});
@@ -844,6 +982,7 @@ export class Editor {
// Edit button disabled after the event took place
await this.addToggleEditButton();
await this.addEditButtonText();
+ this._syncPlacementSnapTargets(null);
}
}
@@ -909,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 = '';
@@ -923,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();
}
}
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 f6a59abe..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) {
@@ -95,7 +122,12 @@ export class MapEntity implements EntityDTO {
bubblingMouseEvents: 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,12 +213,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,
@@ -205,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 223974d5..45ad9fa1 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 });
+ 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/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;
diff --git a/src/rule/index.ts b/src/rule/index.ts
index cc3d0050..18c51dbf 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/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)));
+}
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"]
}