From 274153516f0e1d67c2dd077f174d015a522e8475 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 10 Aug 2026 14:30:33 -0500 Subject: [PATCH 1/7] Emit feature:click on the bus for every vector feature click --- .../APIs/JavaScript/Main/Event-Bus-API.md | 1 + src/essence/Basics/Map_/Map_.js | 27 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index c3221c59c..b997f7b48 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -288,6 +288,7 @@ window.mmgisAPI.on('tool:change', ({ toolName }) => { | Event | Payload | Description | |-------|---------|-------------| | `feature:active` | `{ layerName, feature, layer }` | Fired when a feature becomes active/selected | +| `feature:click` | `{ feature, layerName, latlng, pixel }` | Fired on every click on a vector feature, on any layer type and engine. Carries no selection semantics; `layerName` is the layer's uuid | ```javascript window.mmgisAPI.on('feature:active', ({ layerName, feature }) => { diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index f85aeceb0..95b05ff9a 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -398,9 +398,14 @@ let Map_ = { // `map:createLayer`. The whole pick result is forwarded so // consumers can filter by layerId or react to empty-space clicks. if (typeof engine.onFeatureClick === 'function') { - const off = engine.onFeatureClick((info) => + const off = engine.onFeatureClick((info) => { window.mmgisAPI.emit('map:featureClick', info) - ) + if (info?.feature != null) + emitFeatureClick(info.feature, info.layerId, { + latlng: info.latlng, + containerPoint: info.pixel, + }) + }) if (typeof off === 'function') _providerCleanups.push(off) } } @@ -1085,6 +1090,18 @@ function onEachFeatureDefault(feature, layer) { } } +function emitFeatureClick(feature, layerName, e) { + if (!window.mmgisAPI) return + window.mmgisAPI.emit('feature:click', { + feature: feature ?? null, + layerName: layerName ?? null, + latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, + pixel: e?.containerPoint + ? { x: e.containerPoint.x, y: e.containerPoint.y } + : null, + }) +} + Map_.featureDefaultClick = featureDefaultClick function featureDefaultClick(feature, layer, e) { if ( @@ -1092,6 +1109,7 @@ function featureDefaultClick(feature, layer, e) { ToolController_.activeTool.disableLayerInteractions === true ) return + emitFeatureClick(feature, layer?.options?.layerName, e) MetadataCapturer.populateMetadata(layer, () => { Kinds.use( L_.layers.data[layer.options.layerName].kind, @@ -1835,6 +1853,11 @@ function makeVectorTileLayer(layerObj, mapContext = null) { let ell = { latlng: null } if (e.latlng != null) ell.latlng = JSON.parse(JSON.stringify(e.latlng)) + emitFeatureClick( + L_.layers.layer[layerName]?.activeFeatures?.[0], + layerName, + ell + ) MetadataCapturer.populateMetadata(layer, () => { Kinds.use( L_.layers.data[layerName].kind, From 2afad18c7795803b9575f96898bf9a7c4204768d Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 09:39:41 -0500 Subject: [PATCH 2/7] feature:click: snapshot clicked feature through the vector-tile debounce --- src/essence/Basics/Map_/Map_.js | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 17217be39..ccfd8a50d 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -1904,19 +1904,15 @@ function makeVectorTileLayer(layerObj, mapContext = null) { } } var timedSelectTimeout = null - var timedSelect = function (layer, layerName, e) { + var timedSelect = function (layer, layerName, e, clickedFeature) { clearTimeout(timedSelectTimeout) timedSelectTimeout = setTimeout( - (function (layer, layerName, e) { + (function (layer, layerName, e, clickedFeature) { return function () { let ell = { latlng: null } if (e.latlng != null) ell.latlng = JSON.parse(JSON.stringify(e.latlng)) - emitFeatureClick( - L_.layers.layer[layerName]?.activeFeatures?.[0], - layerName, - ell - ) + emitFeatureClick(clickedFeature, layerName, ell) MetadataCapturer.populateMetadata(layer, () => { Kinds.use( L_.layers.data[layerName].kind, @@ -1940,7 +1936,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) { L_.layers.layer[layerName].activeFeatures = [] }) } - })(layer, layerName, e), + })(layer, layerName, e, clickedFeature), 100 ) } @@ -1986,13 +1982,14 @@ function makeVectorTileLayer(layerObj, mapContext = null) { fillOpacity: 1, } ) - L_.layers.layer[layerName].activeFeatures = - L_.layers.layer[layerName].activeFeatures || [] - L_.layers.layer[layerName].activeFeatures.push({ + const clickedFeature = { type: 'Feature', properties: e.layer.properties, geometry: {}, - }) + } + L_.layers.layer[layerName].activeFeatures = + L_.layers.layer[layerName].activeFeatures || [] + L_.layers.layer[layerName].activeFeatures.push(clickedFeature) Map_.activeLayer = e.layer if (Map_.activeLayer) L_.Map_._justSetActiveLayer = true @@ -2025,7 +2022,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) { } } - timedSelect(e.layer, layerName, e) + timedSelect(e.layer, layerName, e, clickedFeature) L.DomEvent.stop(e) }) From a60cf9fb98479844e2c311d346cbe2aae7613a6b Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 09:46:27 -0500 Subject: [PATCH 3/7] feature:click: centralize emit guards; respect disableLayerInteractions on all paths --- .../MapEngines/Adapters/DeckGLHelpers.ts | 6 +++-- src/essence/Basics/Map_/Map_.js | 24 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index df1e635e9..bc814a153 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -145,11 +145,13 @@ export function pickInfoToResult(info: PickingInfo): FeaturePickResult { if (!info.picked) { return { feature: null } } - const [lng, lat] = (info.coordinate as [number, number]) ?? [0, 0] + const coordinate = info.coordinate as [number, number] | undefined return { feature: (info.object as Record) ?? null, layerId: info.layer?.id, - latlng: { lat, lng }, + ...(coordinate + ? { latlng: { lat: coordinate[1], lng: coordinate[0] } } + : {}), pixel: { x: info.x, y: info.y }, } } diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index ccfd8a50d..bbbe53fd5 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -405,11 +405,10 @@ let Map_ = { if (typeof engine.onFeatureClick === 'function') { const off = engine.onFeatureClick((info) => { window.mmgisAPI.emit('map:featureClick', info) - if (info?.feature != null) - emitFeatureClick(info.feature, info.layerId, { - latlng: info.latlng, - containerPoint: info.pixel, - }) + emitFeatureClick(info?.feature, info?.layerId, { + latlng: info?.latlng, + containerPoint: info?.pixel, + }) }) if (typeof off === 'function') _providerCleanups.push(off) } @@ -1097,8 +1096,14 @@ function onEachFeatureDefault(feature, layer) { function emitFeatureClick(feature, layerName, e) { if (!window.mmgisAPI) return + if (feature == null) return + if ( + ToolController_.activeTool && + ToolController_.activeTool.disableLayerInteractions === true + ) + return window.mmgisAPI.emit('feature:click', { - feature: feature ?? null, + feature, layerName: layerName ?? null, latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, pixel: e?.containerPoint @@ -1909,9 +1914,14 @@ function makeVectorTileLayer(layerObj, mapContext = null) { timedSelectTimeout = setTimeout( (function (layer, layerName, e, clickedFeature) { return function () { - let ell = { latlng: null } + let ell = { latlng: null, containerPoint: null } if (e.latlng != null) ell.latlng = JSON.parse(JSON.stringify(e.latlng)) + if (e.containerPoint != null) + ell.containerPoint = { + x: e.containerPoint.x, + y: e.containerPoint.y, + } emitFeatureClick(clickedFeature, layerName, ell) MetadataCapturer.populateMetadata(layer, () => { Kinds.use( From 8e4fa2ff0240e55dda7abca077572cb2f5f0186d Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 09:52:43 -0500 Subject: [PATCH 4/7] feature:click: emit a copied feature instead of a live core reference --- src/essence/Basics/Map_/Map_.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index bbbe53fd5..5ad5b7276 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -1102,8 +1102,12 @@ function emitFeatureClick(feature, layerName, e) { ToolController_.activeTool.disableLayerInteractions === true ) return + const featureCopy = { ...feature } + if (feature.geometry !== undefined) featureCopy.geometry = feature.geometry + if (feature.properties != null) + featureCopy.properties = { ...feature.properties } window.mmgisAPI.emit('feature:click', { - feature, + feature: featureCopy, layerName: layerName ?? null, latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, pixel: e?.containerPoint From 6c5f70b5493e81c39d0e3cc02c2972d4323042bd Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 09:56:44 -0500 Subject: [PATCH 5/7] feature:click: resolve layerName through asLayerUUID on every path --- src/essence/Basics/Map_/Map_.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 5ad5b7276..db1e30318 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -1108,7 +1108,7 @@ function emitFeatureClick(feature, layerName, e) { featureCopy.properties = { ...feature.properties } window.mmgisAPI.emit('feature:click', { feature: featureCopy, - layerName: layerName ?? null, + layerName: L_.asLayerUUID(layerName), latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, pixel: e?.containerPoint ? { x: e.containerPoint.x, y: e.containerPoint.y } From bdcc1adb5fdec12eceae07179ea75272b27f020d Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 09:56:56 -0500 Subject: [PATCH 6/7] feature:click: docs row matches actual emit behavior --- docs/pages/APIs/JavaScript/Main/Event-Bus-API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index b997f7b48..95120b67a 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -288,7 +288,7 @@ window.mmgisAPI.on('tool:change', ({ toolName }) => { | Event | Payload | Description | |-------|---------|-------------| | `feature:active` | `{ layerName, feature, layer }` | Fired when a feature becomes active/selected | -| `feature:click` | `{ feature, layerName, latlng, pixel }` | Fired on every click on a vector feature, on any layer type and engine. Carries no selection semantics; `layerName` is the layer's uuid | +| `feature:click` | `{ feature, layerName, latlng, pixel }` | Fired when a vector feature is clicked on the 2D engines (Leaflet and deck.gl adapters); the 3D globe does not emit it. Also fires on programmatic feature selection (search results, URL restore) with `latlng`/`pixel` `null`. Carries no selection semantics. `feature` is a snapshot copy (shallow, `properties` cloned); `layerName` is the mission layer's uuid, or `null` for layers not in the mission config; `latlng` and `pixel` may each be `null`. Suppressed while the active tool disables layer interactions | ```javascript window.mmgisAPI.on('feature:active', ({ layerName, feature }) => { From 3d1b786186a15e4acf5f9ec46e54c879c215b29f Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 12 Aug 2026 10:00:59 -0500 Subject: [PATCH 7/7] feature:click: extract payload builder and cover it with tests --- src/essence/Basics/Map_/Map_.js | 21 ++-- .../Basics/Map_/featureClickPayload.js | 15 +++ tests/unit/featureClickPayload.spec.js | 95 +++++++++++++++++++ 3 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 src/essence/Basics/Map_/featureClickPayload.js create mode 100644 tests/unit/featureClickPayload.spec.js diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index db1e30318..29c232408 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -16,6 +16,7 @@ import CursorInfo from '../../Ancillary/CursorInfo' import Description from '../../Ancillary/Description' import QueryURL from '../../Ancillary/QueryURL' import MetadataCapturer from '../Layers_/MetadataCapturer.js' +import { buildFeatureClickPayload } from './featureClickPayload' import { compileTileUrl, buildTileUrlOptions, @@ -1096,24 +1097,18 @@ function onEachFeatureDefault(feature, layer) { function emitFeatureClick(feature, layerName, e) { if (!window.mmgisAPI) return - if (feature == null) return if ( ToolController_.activeTool && ToolController_.activeTool.disableLayerInteractions === true ) return - const featureCopy = { ...feature } - if (feature.geometry !== undefined) featureCopy.geometry = feature.geometry - if (feature.properties != null) - featureCopy.properties = { ...feature.properties } - window.mmgisAPI.emit('feature:click', { - feature: featureCopy, - layerName: L_.asLayerUUID(layerName), - latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, - pixel: e?.containerPoint - ? { x: e.containerPoint.x, y: e.containerPoint.y } - : null, - }) + const payload = buildFeatureClickPayload( + feature, + L_.asLayerUUID(layerName), + e + ) + if (payload == null) return + window.mmgisAPI.emit('feature:click', payload) } Map_.featureDefaultClick = featureDefaultClick diff --git a/src/essence/Basics/Map_/featureClickPayload.js b/src/essence/Basics/Map_/featureClickPayload.js new file mode 100644 index 000000000..ef861c832 --- /dev/null +++ b/src/essence/Basics/Map_/featureClickPayload.js @@ -0,0 +1,15 @@ +export function buildFeatureClickPayload(feature, layerName, e) { + if (feature == null) return null + const featureCopy = { ...feature } + if (feature.geometry !== undefined) featureCopy.geometry = feature.geometry + if (feature.properties != null) + featureCopy.properties = { ...feature.properties } + return { + feature: featureCopy, + layerName: layerName ?? null, + latlng: e?.latlng ? { lat: e.latlng.lat, lng: e.latlng.lng } : null, + pixel: e?.containerPoint + ? { x: e.containerPoint.x, y: e.containerPoint.y } + : null, + } +} diff --git a/tests/unit/featureClickPayload.spec.js b/tests/unit/featureClickPayload.spec.js new file mode 100644 index 000000000..db26a92ac --- /dev/null +++ b/tests/unit/featureClickPayload.spec.js @@ -0,0 +1,95 @@ +import { test, expect } from 'vitest' +import { buildFeatureClickPayload } from '../../src/essence/Basics/Map_/featureClickPayload.js' + +const clickEvent = { + latlng: { lat: 34.2, lng: -118.1 }, + containerPoint: { x: 120, y: 340 }, +} + +test.describe('buildFeatureClickPayload', () => { + test('returns null without a feature', () => { + expect(buildFeatureClickPayload(null, 'uuid-1', clickEvent)).toBeNull() + expect( + buildFeatureClickPayload(undefined, 'uuid-1', clickEvent) + ).toBeNull() + }) + + test('maps a full click to the payload shape', () => { + const feature = { + type: 'Feature', + properties: { name: 'station-a' }, + geometry: { type: 'Point', coordinates: [-118.1, 34.2] }, + } + expect(buildFeatureClickPayload(feature, 'uuid-1', clickEvent)).toEqual( + { + feature, + layerName: 'uuid-1', + latlng: { lat: 34.2, lng: -118.1 }, + pixel: { x: 120, y: 340 }, + } + ) + }) + + test('copies the feature and its properties', () => { + const feature = { + type: 'Feature', + properties: { name: 'station-a' }, + geometry: {}, + } + const payload = buildFeatureClickPayload(feature, 'uuid-1', clickEvent) + expect(payload.feature).not.toBe(feature) + expect(payload.feature.properties).not.toBe(feature.properties) + + feature.properties.appendedLater = true + expect(payload.feature.properties.appendedLater).toBeUndefined() + + payload.feature.properties.name = 'mutated-by-consumer' + expect(feature.properties.name).toBe('station-a') + }) + + test('preserves a non-enumerable lazy geometry getter', () => { + const feature = { type: 'Feature', properties: {} } + Object.defineProperty(feature, 'geometry', { + enumerable: false, + get: () => ({ type: 'Point', coordinates: [10, 20] }), + }) + const payload = buildFeatureClickPayload(feature, 'uuid-1', clickEvent) + expect(payload.feature.geometry).toEqual({ + type: 'Point', + coordinates: [10, 20], + }) + }) + + test('nulls latlng and pixel when the event lacks them', () => { + const feature = { type: 'Feature', properties: {} } + expect(buildFeatureClickPayload(feature, 'uuid-1', null)).toEqual({ + feature, + layerName: 'uuid-1', + latlng: null, + pixel: null, + }) + expect( + buildFeatureClickPayload(feature, 'uuid-1', { latlng: null }) + ).toMatchObject({ latlng: null, pixel: null }) + }) + + test('keeps zero-valued coordinates and pixels', () => { + const feature = { type: 'Feature', properties: {} } + const payload = buildFeatureClickPayload(feature, 'uuid-1', { + latlng: { lat: 0, lng: 0 }, + containerPoint: { x: 0, y: 0 }, + }) + expect(payload.latlng).toEqual({ lat: 0, lng: 0 }) + expect(payload.pixel).toEqual({ x: 0, y: 0 }) + }) + + test('passes layerName through and nulls it when absent', () => { + const feature = { type: 'Feature', properties: {} } + expect( + buildFeatureClickPayload(feature, 'uuid-1', clickEvent).layerName + ).toBe('uuid-1') + expect( + buildFeatureClickPayload(feature, null, clickEvent).layerName + ).toBeNull() + }) +})