Skip to content
Open
1 change: 1 addition & 0 deletions docs/pages/APIs/JavaScript/Main/Event-Bus-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 }) => {
Expand Down
6 changes: 4 additions & 2 deletions src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) ?? null,
layerId: info.layer?.id,
latlng: { lat, lng },
...(coordinate
? { latlng: { lat: coordinate[1], lng: coordinate[0] } }
: {}),
pixel: { x: info.x, y: info.y },
}
}
Expand Down
51 changes: 40 additions & 11 deletions src/essence/Basics/Map_/Map_.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -403,9 +404,13 @@ 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)
)
emitFeatureClick(info?.feature, info?.layerId, {
latlng: info?.latlng,
containerPoint: info?.pixel,
})
})
if (typeof off === 'function') _providerCleanups.push(off)
}
}
Expand Down Expand Up @@ -1090,13 +1095,30 @@ function onEachFeatureDefault(feature, layer) {
}
}

function emitFeatureClick(feature, layerName, e) {
if (!window.mmgisAPI) return
if (
ToolController_.activeTool &&
ToolController_.activeTool.disableLayerInteractions === true
)
return
Comment on lines +1100 to +1104

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for?

const payload = buildFeatureClickPayload(
feature,
L_.asLayerUUID(layerName),
e
)
if (payload == null) return
window.mmgisAPI.emit('feature:click', payload)
}

Map_.featureDefaultClick = featureDefaultClick
function featureDefaultClick(feature, layer, e) {
if (
ToolController_.activeTool &&
ToolController_.activeTool.disableLayerInteractions === true
)
return
emitFeatureClick(feature, layer?.options?.layerName, e)
MetadataCapturer.populateMetadata(layer, () => {
Kinds.use(
L_.layers.data[layer.options.layerName].kind,
Expand Down Expand Up @@ -1886,14 +1908,20 @@ 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 }
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(
L_.layers.data[layerName].kind,
Expand All @@ -1917,7 +1945,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) {
L_.layers.layer[layerName].activeFeatures = []
})
}
})(layer, layerName, e),
})(layer, layerName, e, clickedFeature),
100
)
}
Expand Down Expand Up @@ -1963,13 +1991,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
Expand Down Expand Up @@ -2002,7 +2031,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) {
}
}

timedSelect(e.layer, layerName, e)
timedSelect(e.layer, layerName, e, clickedFeature)

L.DomEvent.stop(e)
})
Expand Down
15 changes: 15 additions & 0 deletions src/essence/Basics/Map_/featureClickPayload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function buildFeatureClickPayload(feature, layerName, e) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method seems overcomplicated, many unnecessary if conditionals and redundant assignments. can you simplify?

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,
}
}
95 changes: 95 additions & 0 deletions tests/unit/featureClickPayload.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading