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 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!) 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"] }