diff --git a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss
index 9d602deae..073f03944 100644
--- a/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss
+++ b/src/essence/Tools/AOI/AOIComponent/AOIComponent.scss
@@ -1,4 +1,4 @@
-// AOI plugin — scoped under the .aoi-tool / .aoi-tooltip roots only.
+// AOI plugin — scoped under the .aoi-tool root only.
//
// Every value resolves against the global design tokens exported by
// src/styles/_theme-export.scss as :root --theme-* custom properties; the
@@ -16,8 +16,7 @@
// picture — hover, focus, disabled — use the accent tint the rest of the map
// controls use.
-.aoi-tool,
-.aoi-tooltip {
+.aoi-tool {
box-sizing: border-box;
color: var(--theme-color-ink, #17171b);
font-family: var(--theme-font-body, 'Public Sans', system-ui, sans-serif);
@@ -25,8 +24,7 @@
background: var(--theme-color-white, #ffffff);
}
-.aoi-tool *,
-.aoi-tooltip * {
+.aoi-tool * {
box-sizing: border-box;
}
@@ -35,8 +33,7 @@
// matched declaration beats one inherited from the list — however specific the
// list's own selector is — so every list in the tool would render in that pale
// gray. Restoring `inherit` hands the cascade back to the list.
-.aoi-tool li,
-.aoi-tooltip li {
+.aoi-tool li {
color: var(--theme-color-base-dark, #58585b);
}
@@ -44,8 +41,7 @@
// focusable control in the plugin restores one here.
.aoi-tool button:focus-visible,
.aoi-tool input:focus-visible,
-.aoi-tool [tabindex]:focus-visible,
-.aoi-tooltip button:focus-visible {
+.aoi-tool [tabindex]:focus-visible {
outline: var(--theme-border-width-md, 2px) solid var(--theme-color-primary, #1c67e3);
outline-offset: -2px;
}
@@ -619,61 +615,3 @@
line-height: 1.6;
}
-.aoi-tooltip {
- position: absolute;
- transform: translate(-50%, calc(-100% - var(--theme-spacing-105, 12px)));
- min-width: 220px;
- padding: var(--theme-spacing-105, 12px);
- border-radius: var(--theme-radius-sm, 2px);
- box-shadow: 0 4px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15));
- pointer-events: auto;
- z-index: 1000;
-}
-
-.aoi-tooltip__label {
- margin: 0 0 var(--theme-spacing-1, 8px);
- font-size: var(--theme-font-size-2xs, 14px);
- font-weight: var(--theme-font-weight-semibold, 600);
-}
-
-.aoi-tooltip__actions {
- display: flex;
- gap: var(--theme-spacing-1, 8px);
-}
-
-.aoi-tooltip__primary,
-.aoi-tooltip__secondary {
- flex: 1 1 auto;
- height: 32px;
- border: 0;
- border-radius: var(--theme-radius-sm, 2px);
- cursor: pointer;
- font-family: inherit;
- font-size: var(--theme-font-size-2xs, 14px);
- font-weight: var(--theme-font-weight-semibold, 600);
- transition: background-color 0.12s ease, color 0.12s ease;
-}
-
-.aoi-tooltip__primary {
- background: var(--theme-color-primary, #1c67e3);
- color: var(--theme-color-white, #ffffff);
-}
-
-.aoi-tooltip__primary:hover:not(:disabled) {
- background: var(--theme-color-primary-dark, #0b3d91);
-}
-
-.aoi-tooltip__primary:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.aoi-tooltip__secondary {
- background: var(--theme-color-base-lightest, #f6f6f6);
- color: var(--theme-color-ink, #17171b);
-}
-
-.aoi-tooltip__secondary:hover {
- background: var(--theme-color-primary-lightest, #eaf0fd);
- color: var(--theme-color-primary, #1c67e3);
-}
diff --git a/src/essence/Tools/AOI/AOITool.js b/src/essence/Tools/AOI/AOITool.js
index f627313d5..c2d644a84 100644
--- a/src/essence/Tools/AOI/AOITool.js
+++ b/src/essence/Tools/AOI/AOITool.js
@@ -1,7 +1,7 @@
/**
* AOI plugin — MMGIS wrapper.
*
- * Pluggable contract (see specs/012-aoi-plugin/plan.md and PLUGIN-DEVELOPMENT-GUIDE.md):
+ * Pluggable contract:
*
* pluginId: 'aoi' — derived from this plugin's binding at build time. The
* tool controller mints the plugin-scoped bus handle from it and injects it
@@ -12,16 +12,15 @@
* - areaDrawn { feature, source: 'search'|'draw'|'upload'|'inspect' }
* - analysisAOIReady { feature } — consumed by the FetchStats plugin
* - drawingCleared {}
- * - drawingCancelled {}
*
* Provides (auto-prefixed plugin:aoi:):
- * - getCurrentSelection -> { feature, source } | null
+ * - getCurrentSelection -> { feature, source, label } | null
*
* Listens to:
- * - tool:change (core)
* - map:drawstart / drawvertex /
* drawcomplete / drawcancel (engine bus)
* - map:featureClick (inspect-mode boundary clicks, filtered by layerId)
+ * - map:moveend (one-shot, while a selection waits for the camera)
* - plugin:fetchstats:analysisProgress { done, total }
* - plugin:fetchstats:analysisReady { analysisData }
* - plugin:fetchstats:analysisSkipped { reason }
@@ -30,17 +29,16 @@
* - map:createLayer / map:removeLayer
* - map:getBounds / map:fitBounds
* - map:enableDrawing / map:disableDrawing / map:finishDrawing
- * - map:addOverlay / map:removeOverlay
+ * - map:showPopup (resolves with how the popup closed) / map:hidePopup
* - plugins:setState
-
- * AOIComponent.tsx and AOITooltip.tsx must stay MMGIS-agnostic.
+ *
+ * AOIComponent.tsx must stay MMGIS-agnostic.
*/
import React from 'react'
import { createRoot } from 'react-dom/client'
import AOIComponent from './AOIComponent'
-import AOITooltip from './AOITooltip'
import { mmgisSetPluginState } from '../_shared/adapters/mmgisAPI'
import {
buildSearchIndex,
@@ -51,7 +49,7 @@ import {
featureCentroid,
featureBounds,
selectionFitBounds,
- selectionTooltipAnchor,
+ selectionPopupAnchor,
} from './aoiHelpers'
import { loadBoundaries } from './aoiBoundaryLoader'
@@ -60,7 +58,6 @@ const DEFAULT_DRAW_SHAPES = ['polygon', 'rectangle', 'circle']
const VALID_DRAW_SHAPES = new Set(['point', 'linestring', 'polygon', 'rectangle', 'circle'])
const SELECTION_LAYER_ID = 'aoi:selection'
const INSPECT_BOUNDARIES_LAYER_ID = 'aoi:inspect-boundaries'
-const TOOLTIP_OVERLAY_ID = 'aoi:tooltip'
// ── Draw-session keys ──────────────────────────────────────────────────────────
// Components with these roles handle Escape themselves — a dialog, menu,
@@ -141,6 +138,11 @@ const AOITool = {
_cleanups: [],
_analysisErrorTimeout: null,
_drawKeyHandler: null,
+ // Cancels the deferred popup show while the camera is still moving.
+ _pendingPopup: null,
+ // The selection a drawing session took the card away from, held until the
+ // session either replaces it or is backed out of.
+ _suspendedAOI: null,
// ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -177,37 +179,35 @@ const AOITool = {
this._setState({ searchLoading: false, searchDisabled: true })
})
- const api = window.mmgisAPI
- if (api?.on) {
- const subscribe = (event, handler) => {
- const off = api.on(event, handler)
- this._cleanups.push(typeof off === 'function' ? off : () => { })
- }
- subscribe('tool:change', () => this._clearSelection())
- subscribe('map:drawstart', () => this._onDrawStart())
- subscribe('map:drawvertex', (e) => this._onDrawVertex(e))
- subscribe('map:drawcomplete', (e) => this._onDrawComplete(e))
- subscribe('map:drawcancel', () => this._onDrawCancelEvent())
- subscribe('map:featureClick', (info) => this._onMapFeatureClick(info))
- subscribe('plugin:fetchstats:analysisProgress', ({ done, total }) => {
- if (done === 0) {
- this._setState({
- analysisStatus: 'running',
- analysisLabel: this._state.currentAOI?.label || 'Area of interest',
- analysisDone: 0,
- analysisTotal: total,
- })
- } else {
- this._setState({ analysisDone: done })
- }
- })
- subscribe('plugin:fetchstats:analysisReady', () => {
- this._setState({ analysisStatus: 'idle' })
- })
- subscribe('plugin:fetchstats:analysisSkipped', ({ reason } = {}) => {
- this._showAnalysisError(this._messageForSkipReason(reason))
- })
+ // Subscriptions go through the handle as well: it hands back a
+ // disposer for each one, and destroy() drains them from `_cleanups`.
+ const subscribe = (event, handler) => {
+ const off = this.api?.on(event, handler)
+ this._cleanups.push(typeof off === 'function' ? off : () => { })
}
+ subscribe('map:drawstart', (e) => this._onDrawStart(e))
+ subscribe('map:drawvertex', (e) => this._onDrawVertex(e))
+ subscribe('map:drawcomplete', (e) => this._onDrawComplete(e))
+ subscribe('map:drawcancel', () => this._onDrawCancelEvent())
+ subscribe('map:featureClick', (info) => this._onMapFeatureClick(info))
+ subscribe('plugin:fetchstats:analysisProgress', ({ done, total }) => {
+ if (done === 0) {
+ this._setState({
+ analysisStatus: 'running',
+ analysisLabel: this._state.currentAOI?.label || 'Area of interest',
+ analysisDone: 0,
+ analysisTotal: total,
+ })
+ } else {
+ this._setState({ analysisDone: done })
+ }
+ })
+ subscribe('plugin:fetchstats:analysisReady', () => {
+ this._setState({ analysisStatus: 'idle' })
+ })
+ subscribe('plugin:fetchstats:analysisSkipped', ({ reason } = {}) => {
+ this._showAnalysisError(this._messageForSkipReason(reason))
+ })
this._render()
this.made = true
@@ -228,13 +228,18 @@ const AOITool = {
this._analysisErrorTimeout = null
}
+ this._cancelPendingPopup()
+ // Nothing of the selection outlives the tool, so the cancel below has
+ // no card to put back.
+ this._suspendedAOI = null
+
// Fire-and-forget: cancel any active drawing session via the bus.
this._removeDrawKeys()
- window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { })
+ this.api?.request('map:disableDrawing').catch(() => { })
this._removeSelectionLayer()
this._hideInspectBoundaries()
- this._hideTooltip()
+ this._hidePopup()
if (this._reactRoot) {
this._reactRoot.unmount()
@@ -363,7 +368,7 @@ const AOITool = {
if (prev === 'draw') {
// Cancel any in-flight drawing session when leaving Draw mode.
// The bus handler is a no-op if no session is active.
- window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { })
+ this.api?.request('map:disableDrawing').catch(() => { })
}
this._setState({ mode: nextMode })
@@ -398,13 +403,31 @@ const AOITool = {
_onDrawShapeChange(shape) {
this._setState({ drawShape: shape, drawVerticesCount: 0 })
- window.mmgisAPI?.request?.('map:enableDrawing', { shape })
+ // The engines end any live session without a cancel of its own before
+ // starting the new one, so a swap reaches the handlers below as a
+ // `drawstart` alone and the suspended selection stays suspended.
+ this.api?.request('map:enableDrawing', { shape })
.catch((err) => console.warn('[AOI] enableDrawing failed', err))
},
- _onDrawStart() {
+ /**
+ * Arming a session is not the same act as replacing the selection, so the
+ * selection stays until a vertex actually lands. Its card cannot: it would
+ * sit over the map for the whole session, taking the Escape and Enter the
+ * session needs and offering to analyze an area being replaced.
+ */
+ _onDrawStart(e) {
+ this._cancelPendingPopup()
+ this._suspendedAOI = this._state.currentAOI
+ this._hidePopup()
this._installDrawKeys()
- this._setState({ isDrawing: true, drawVerticesCount: 0 })
+ this._setState({
+ isDrawing: true,
+ // The shape the engine actually started: its word on which session
+ // is live outranks the one the panel asked for.
+ drawShape: e?.shape ?? this._state.drawShape,
+ drawVerticesCount: 0,
+ })
},
/**
@@ -429,9 +452,9 @@ const AOITool = {
target?.closest?.(KEY_OWNING_ROLE_SELECTOR)
) return
if (evt.key === 'Escape') {
- window.mmgisAPI?.request?.('map:disableDrawing').catch(() => { })
+ this.api?.request('map:disableDrawing').catch(() => { })
} else if (evt.key === 'Enter') {
- window.mmgisAPI?.request?.('map:finishDrawing').catch(() => { })
+ this.api?.request('map:finishDrawing').catch(() => { })
}
}
document.addEventListener('keydown', this._drawKeyHandler)
@@ -444,6 +467,11 @@ const AOITool = {
},
_onDrawVertex(e) {
+ // The first vertex is where the replacement begins, so this is where
+ // the previous selection goes: every shape reports its first committed
+ // vertex — a polygon's first click, a rectangle's first corner, a
+ // circle's centre, the point itself — before it can complete.
+ this._dropSuspendedSelection()
const count = Array.isArray(e?.vertices) ? e.vertices.length : 0
this._setState({ drawVerticesCount: count })
},
@@ -452,7 +480,13 @@ const AOITool = {
this._removeDrawKeys()
this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 })
const feature = e?.feature
- if (!feature) return
+ if (!feature) {
+ // Defence against a payload with no feature: there is nothing to
+ // select, so the previous selection is left as it was found.
+ this._restoreSuspendedSelection()
+ return
+ }
+ this._suspendedAOI = null
const label = feature.properties?.shape
? `Drawn ${feature.properties.shape}`
: 'Drawn area'
@@ -462,6 +496,32 @@ const AOITool = {
_onDrawCancelEvent() {
this._removeDrawKeys()
this._setState({ isDrawing: false, drawShape: null, drawVerticesCount: 0 })
+ this._restoreSuspendedSelection()
+ },
+
+ /** Let go of the selection a session suspended: it is being replaced. */
+ _dropSuspendedSelection() {
+ if (!this._suspendedAOI) return
+ this._suspendedAOI = null
+ this._clearSelection()
+ },
+
+ /**
+ * Put back the card of a selection a session suspended — only the card; the
+ * selection never left the map, and the camera may not be framing it.
+ */
+ _restoreSuspendedSelection() {
+ const aoi = this._suspendedAOI
+ this._suspendedAOI = null
+ if (!aoi || this._state.currentAOI !== aoi) return
+ this.api?.request('map:getBounds')
+ .catch(() => null)
+ .then((view) => {
+ // The read is a hop: across it the selection can be replaced
+ // or cleared, and a fresh session can arm.
+ if (this._state.currentAOI !== aoi || this._state.isDrawing) return
+ this._showSelectionPopup(aoi.feature, aoi.label, view)
+ })
},
// ── Inspect mode ───────────────────────────────────────────────────────────
@@ -469,7 +529,7 @@ const AOITool = {
_showInspectBoundaries() {
const entries = this._state.searchAllEntries
if (!entries.length) return
- const api = window.mmgisAPI
+ const api = this.api
if (!api?.request) return
// Sort largest-area first so big polygons (e.g. "United States") render
@@ -498,7 +558,7 @@ const AOITool = {
},
_hideInspectBoundaries() {
- window.mmgisAPI?.request?.('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID })
+ this.api?.request('map:removeLayer', { id: INSPECT_BOUNDARIES_LAYER_ID })
.catch(() => { })
},
@@ -569,10 +629,16 @@ const AOITool = {
// ── Selection lifecycle ────────────────────────────────────────────────────
_applySelection(feature, source, label) {
+ this._cancelPendingPopup()
this._removeSelectionLayer()
-
- const api = window.mmgisAPI
- api?.request?.('map:createLayer', {
+ // This feature is the current selection from here on, so a session has
+ // no suspended selection left for its next vertex to drop.
+ this._suspendedAOI = null
+
+ // Everything goes through AOI's handle: it stamps each request with
+ // AOI's address and hands back a disposer for each subscription.
+ const api = this.api
+ api?.request('map:createLayer', {
id: SELECTION_LAYER_ID,
type: 'vector',
geojson: { type: 'FeatureCollection', features: [feature] },
@@ -581,73 +647,83 @@ const AOITool = {
}).catch((err) => console.warn('[AOI] failed to add selection layer', err))
this._state.currentAOI = { feature, source, label }
- this.api?.emit('areaDrawn', { feature, source })
+ api?.emit('areaDrawn', { feature, source })
- const c = featureCentroid(feature)
- // `view` keeps the tooltip on-screen when the camera does not move; omit
- // it once the camera has been fitted to the selection.
- const showTooltip = (view) => {
- if (c) {
- this._showTooltip({
- label,
- latlng: selectionTooltipAnchor({ lat: c[1], lng: c[0] }, view),
- analyzeEnabled: true,
- })
- }
- }
+ const showPopup = (view) => this._showSelectionPopup(feature, label, view)
const bbox = featureBounds(feature)
- if (bbox && api?.request && api?.on && api?.off) {
+ if (bbox && api?.request && api?.on) {
+ // Pending from here on, before the camera is even read: a teardown
+ // or a superseding selection during that async hop must drop this
+ // popup. `disarm` is filled in only if the show waits on the camera.
+ let disarm = null
+ const cancel = () => disarm?.()
+ this._pendingPopup = cancel
+
+ // Pass a view to `showPopup` only when the camera never moved. Once
+ // fitBounds has framed the selection, its centroid is on-screen and
+ // needs no fallback anchor.
+ const settled = (unmovedView) => {
+ // Only the still-current show may fire: moveend, the fallback
+ // timer and a rejected fitBounds arbitrate to one popup.
+ if (this._pendingPopup !== cancel) return
+ this._cancelPendingPopup()
+ showPopup(unmovedView)
+ }
+
// Leave the camera alone unless the selection extends beyond the
// current view; then fit its extent minimally (selectionFitBounds).
api.request('map:getBounds')
.catch(() => null)
.then((view) => {
+ if (this._pendingPopup !== cancel) return
const fit = selectionFitBounds(bbox, view)
if (!fit) {
- showTooltip(view)
+ // The camera stays put, so no moveend is coming:
+ // open the popup now.
+ settled(view)
return
}
- // Defer the tooltip until the fitBounds animation settles so it
- // mounts at the final centroid pixel instead of flickering through
- // intermediate positions during the camera move.
- let fallback
- // Pass a view here only when the camera never moved. Once
- // fitBounds has framed the selection, its centroid is
- // on-screen and needs no fallback anchor.
- const settle = (unmovedView) => {
- api.off('map:moveend', oneShot)
- clearTimeout(fallback)
- showTooltip(unmovedView)
+ // Subscribe before the fit: `request` runs its provider
+ // synchronously, so a transitionless fit emits `moveend`
+ // inside the call.
+ const oneShot = () => settled()
+ const timer = setTimeout(oneShot, 1500)
+ const offMoveend = api.on('map:moveend', oneShot)
+ disarm = () => {
+ clearTimeout(timer)
+ offMoveend?.()
}
- // `map:moveend` hands its listener a view state
- // ({ longitude, latitude, zoom }), not a ViewBounds. This
- // wrapper drops that payload so `settle` is called with no
- // view at all.
- const oneShot = () => settle()
- api.on('map:moveend', oneShot)
- // Safety net: if no moveend fires (e.g. an engine that
- // skips the event on a programmatic fit), show the tooltip
- // after a short timeout anyway.
- fallback = setTimeout(oneShot, 1500)
api.request('map:fitBounds', fit).catch((err) => {
console.warn('[AOI] fitBounds failed', err)
- settle(view)
+ // The fit never happened, so the view read above is
+ // still the one on screen: anchor against it.
+ settled(view)
})
})
- .catch((err) =>
+ .catch((err) => {
console.warn('[AOI] selection camera step failed', err)
- )
+ // Nothing can open this popup any more, so release the
+ // pending slot — but only while it is still this chain's;
+ // a superseding selection owns its own show.
+ if (this._pendingPopup === cancel) this._cancelPendingPopup()
+ })
} else {
- showTooltip()
+ showPopup()
}
},
+ /** Drop a popup that is still waiting for the camera to settle. */
+ _cancelPendingPopup() {
+ if (!this._pendingPopup) return
+ this._pendingPopup()
+ this._pendingPopup = null
+ },
+
_clearSelection() {
if (!this._state.currentAOI) return
this._removeSelectionLayer()
- this._hideTooltip()
this._state.currentAOI = null
this.api?.emit('drawingCleared', {})
this._render()
@@ -655,55 +731,61 @@ const AOITool = {
_removeSelectionLayer() {
// removeLayer is idempotent; no need for a hasLayer pre-check.
- window.mmgisAPI?.request?.('map:removeLayer', { id: SELECTION_LAYER_ID })
+ this.api?.request('map:removeLayer', { id: SELECTION_LAYER_ID })
.catch(() => { })
},
- // ── Tooltip overlay ────────────────────────────────────────────────────────
+ // ── Popup ──────────────────────────────────────────────────────────────────
/**
- * Show the analyze/cancel tooltip anchored to a feature centroid.
- * Core's `map:addOverlay` owns the DOM and repositions on view change.
+ * Show the card at the feature centroid — or, when `view` is given and the
+ * centroid is off it, at the view's centre (see {@link selectionPopupAnchor}).
*/
- _showTooltip({ label, latlng, analyzeEnabled }) {
- const api = window.mmgisAPI
- if (!api?.request) return
- api.request('map:addOverlay', {
- id: TOOLTIP_OVERLAY_ID,
- latlng,
- mount: (node) => {
- const tooltipRoot = createRoot(node)
- tooltipRoot.render(
- React.createElement(AOITooltip, {
- label,
- position: { x: 0, y: 0 },
- analyzeEnabled,
- onAnalyze: () => this._onAnalyze(),
- onCancel: () => this._onCancel(),
- })
- )
- return () => tooltipRoot.unmount()
- },
- }).catch((err) => console.warn('[AOI] addOverlay failed', err))
+ _showSelectionPopup(feature, label, view) {
+ const c = featureCentroid(feature)
+ if (!c) return
+ this._showPopup(label, selectionPopupAnchor({ lat: c[1], lng: c[0] }, view))
},
- _hideTooltip() {
- window.mmgisAPI?.request?.('map:removeOverlay', { id: TOOLTIP_OVERLAY_ID })
- .catch(() => { })
+ /** Core owns the card; the request is data only and answers with how it closed. */
+ _showPopup(label, latlng) {
+ this.api
+ ?.request('map:showPopup', {
+ latlng,
+ title: label,
+ secondaryAction: { label: 'Cancel' },
+ primaryAction: { label: 'Analyze area' },
+ })
+ // Two-arg `then`, so the rejection handler covers the request only
+ // and a throw out of the outcome branches is not reported as a
+ // failure to show the popup.
+ .then(
+ ({ action } = {}) => {
+ // Dismissals abandon the selection; 'closed' means AOI or
+ // core took the card away.
+ if (action === 'primary') this._onAnalyze()
+ else if (action === 'secondary' || action === 'dismiss') {
+ this._clearSelection()
+ }
+ },
+ (err) => console.warn('[AOI] showPopup failed', err)
+ )
+ },
+
+ _hidePopup() {
+ this.api?.request('map:hidePopup').catch(() => { })
},
// ── Analysis hand-off ──────────────────────────────────────────────────────
+ /**
+ * The popup's result carries no data, so the feature is attached here:
+ * `analysisAOIReady` is what reaches the FetchStats and Chart plugins.
+ */
_onAnalyze() {
const aoi = this._state.currentAOI
if (!aoi) return
this.api?.emit('analysisAOIReady', { feature: aoi.feature })
- this._hideTooltip()
- },
-
- _onCancel() {
- this.api?.emit('drawingCancelled', {})
- this._clearSelection()
},
}
diff --git a/src/essence/Tools/AOI/AOITooltip.tsx b/src/essence/Tools/AOI/AOITooltip.tsx
deleted file mode 100644
index 5c2fa8150..000000000
--- a/src/essence/Tools/AOI/AOITooltip.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react'
-import './AOIComponent/AOIComponent.scss'
-
-export interface AOITooltipProps {
- label: string
- position: { x: number; y: number }
- analyzeEnabled: boolean
- onAnalyze: () => void
- onCancel: () => void
-}
-
-export function AOITooltip(props: AOITooltipProps) {
- return (
-
-
{props.label}
-
-
-
-
-
- )
-}
-
-export default AOITooltip
diff --git a/src/essence/Tools/AOI/aoiHelpers.ts b/src/essence/Tools/AOI/aoiHelpers.ts
index d50852703..0870b3c87 100644
--- a/src/essence/Tools/AOI/aoiHelpers.ts
+++ b/src/essence/Tools/AOI/aoiHelpers.ts
@@ -197,53 +197,64 @@ function textOf(el: Element, tag: string): string | null {
return node?.textContent?.trim() || null
}
-export function featureCentroid(f: Feature): [number, number] | null {
- const g = f.geometry
+/**
+ * A geometry as a list of vertex lists — rings for the polygons, the line
+ * itself for the linestrings, a one-vertex list for a point. The centroid and
+ * the bounds both read a selection through this, so the two agree on which
+ * geometries a selection can be made of.
+ */
+function vertexParts(g: Geometry | null | undefined): number[][][] | null {
if (!g) return null
- if (g.type === 'Point') {
- const c = g.coordinates as [number, number]
- return [c[0], c[1]]
- }
- if (g.type === 'Polygon' || g.type === 'MultiPolygon') {
- const rings: number[][][] =
- g.type === 'Polygon'
- ? (g.coordinates as number[][][])
- : ((g.coordinates as number[][][][]).flat() as number[][][])
- let sx = 0
- let sy = 0
- let n = 0
- for (const ring of rings) {
- const last = ring.length - 1
- const stopAt =
- ring.length > 1 &&
- ring[0][0] === ring[last][0] &&
- ring[0][1] === ring[last][1]
- ? last
- : ring.length
- for (let i = 0; i < stopAt; i++) {
- sx += ring[i][0]
- sy += ring[i][1]
- n++
- }
+ if (g.type === 'Point') return [[g.coordinates as number[]]]
+ if (g.type === 'Polygon') return g.coordinates as number[][][]
+ if (g.type === 'MultiPolygon')
+ return (g.coordinates as number[][][][]).flat() as number[][][]
+ if (g.type === 'LineString') return [g.coordinates as number[][]]
+ if (g.type === 'MultiLineString') return g.coordinates as number[][][]
+ return null
+}
+
+/** The unweighted mean of a selection's vertices. */
+export function featureCentroid(f: Feature): [number, number] | null {
+ const parts = vertexParts(f.geometry)
+ if (!parts) return null
+
+ let sx = 0
+ let sy = 0
+ let n = 0
+ for (const part of parts) {
+ // A closed ring repeats its first vertex as its last; count it once.
+ const last = part.length - 1
+ const stopAt =
+ part.length > 1 &&
+ part[0][0] === part[last][0] &&
+ part[0][1] === part[last][1]
+ ? last
+ : part.length
+ for (let i = 0; i < stopAt; i++) {
+ sx += part[i][0]
+ sy += part[i][1]
+ n++
}
- return n > 0 ? [sx / n, sy / n] : null
}
- return null
+ return n > 0 ? [sx / n, sy / n] : null
}
+/**
+ * The west/south/east/north envelope of a selection's vertices. A point, and a
+ * line with no width or no height, give a degenerate box — too thin for
+ * {@link selectionFitBounds} to frame, which leaves the camera where it is and
+ * the popup anchored against the view it can still see.
+ */
export function featureBounds(f: Feature): [number, number, number, number] | null {
- const g = f.geometry
- if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) return null
- const rings: number[][][] =
- g.type === 'Polygon'
- ? (g.coordinates as number[][][])
- : ((g.coordinates as number[][][][]).flat() as number[][][])
+ const parts = vertexParts(f.geometry)
+ if (!parts) return null
let w = Infinity
let s = Infinity
let e = -Infinity
let n = -Infinity
- for (const ring of rings) {
- for (const [x, y] of ring) {
+ for (const part of parts) {
+ for (const [x, y] of part) {
if (x < w) w = x
if (y < s) s = y
if (x > e) e = x
@@ -347,7 +358,7 @@ export function selectionFitBounds(
}
/**
- * Pick where to anchor the selection tooltip. The tooltip holds the only
+ * Pick where to anchor the selection's popup card. The card holds the only
* Analyze and Cancel buttons a selection has, so mounting it off-screen
* strands the selection.
*
@@ -360,7 +371,7 @@ export function selectionFitBounds(
* Pass no view once the camera has been fitted to the selection — the centroid
* is on-screen by then.
*/
-export function selectionTooltipAnchor(
+export function selectionPopupAnchor(
centroid: { lat: number; lng: number },
view?: ViewBounds | null
): { lat: number; lng: number } {
diff --git a/tests/unit/AOITool.spec.js b/tests/unit/AOITool.spec.js
new file mode 100644
index 000000000..f6da29216
--- /dev/null
+++ b/tests/unit/AOITool.spec.js
@@ -0,0 +1,541 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+
+// The panel component pulls in @trussworks/react-uswds and a SCSS entry point;
+// nothing here renders it.
+vi.mock('../../src/essence/Tools/AOI/AOIComponent', () => ({ default: () => null }))
+vi.mock('react-dom/client', () => ({
+ createRoot: () => ({ render() { }, unmount() { } }),
+}))
+
+import AOITool from '../../src/essence/Tools/AOI/AOITool'
+
+const polygon = (ring) => ({
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'Polygon', coordinates: [ring] },
+})
+
+// Centroid (5, 5), bounds [0, 0, 10, 10].
+const SQUARE = polygon([[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]])
+// Centroid (25, 25), so a superseding selection is distinguishable.
+const FAR_SQUARE = polygon([[20, 20], [20, 30], [30, 30], [30, 20], [20, 20]])
+
+// The view `map:getBounds` reports. It holds neither square, so every
+// selection here overflows it and the popup waits on a camera move — the
+// path this file is about.
+const VIEW = {
+ southWest: { lat: -40, lng: -40 },
+ northEast: { lat: -30, lng: -30 },
+}
+
+/**
+ * Stand-ins for the global bus (`window.mmgisAPI`) and the handle the
+ * controller injects as `AOITool.api` — which is what the plugin subscribes,
+ * requests and emits through. Calls are recorded; the popup impls model core's
+ * one-slot contract, every show being answered on its own promise with how its
+ * popup closed.
+ */
+function makeFakeApi() {
+ const listeners = new Map()
+ const requests = []
+ const emits = []
+ const provided = new Map()
+ const requestImpl = new Map()
+
+ let openPopup = null
+ const settleOpen = (action) => {
+ if (!openPopup) return
+ const { resolve } = openPopup
+ openPopup = null
+ resolve({ action })
+ }
+
+ const api = {
+ on(event, handler) {
+ if (!listeners.has(event)) listeners.set(event, new Set())
+ listeners.get(event).add(handler)
+ return () => api.off(event, handler)
+ },
+ off(event, handler) {
+ const set = listeners.get(event)
+ if (set) set.delete(handler)
+ },
+ emit(event, data) {
+ emits.push({ event, data })
+ // Snapshot: a handler may unsubscribe itself while dispatching.
+ Array.from(listeners.get(event) || []).forEach((h) => h(data))
+ },
+ // The provider runs inside the call, before the promise is handed
+ // back, as core's does. It is handed the caller's address the way core
+ // hands it over, in a context argument; a bare bus request has none.
+ request(name, payload, options) {
+ const caller = options?.caller ?? null
+ requests.push({ name, payload, caller })
+ const impl = requestImpl.get(name)
+ try {
+ return Promise.resolve(impl ? impl(payload, { caller }) : true)
+ } catch (err) {
+ return Promise.reject(err)
+ }
+ },
+ provide(name, handler) {
+ provided.set(name, handler)
+ return () => provided.delete(name)
+ },
+
+ // A plugin's bus handle, as `mintHandle` builds it: emits and provides
+ // are prefixed with the plugin's address, requests keep their full name
+ // and are stamped with it, and `on` hands back the disposer for the
+ // subscription it made.
+ handleFor(address) {
+ const prefix = `plugin:${address}:`
+ return {
+ address,
+ on: (event, handler) => api.on(event, handler),
+ emit: (event, data) => api.emit(prefix + event, data),
+ provide: (name, handler) => api.provide(prefix + name, handler),
+ request: (name, data) => api.request(name, data, { caller: address }),
+ release: () => { },
+ }
+ },
+
+ // Test-only accessors.
+ requestImpl,
+ listenerCount: (event) => listeners.get(event)?.size || 0,
+ namesOf: (name) => requests.filter((r) => r.name === name),
+ emitsOf: (event) => emits.filter((e) => e.event === event),
+ getSelection: () => provided.get('plugin:aoi:getCurrentSelection')?.(),
+ /** Close the open popup the way core would, answering its request. */
+ closePopup: (action) => settleOpen(action),
+ hasOpenPopup: () => openPopup !== null,
+ reset() {
+ requests.length = 0
+ emits.length = 0
+ },
+ }
+
+ requestImpl.set('map:getBounds', () => VIEW)
+ // Showing takes the slot and records whose popup it now is.
+ requestImpl.set('map:showPopup', (payload, { caller }) => {
+ settleOpen('closed')
+ return new Promise((resolve) => {
+ openPopup = { payload, resolve, owner: caller }
+ })
+ })
+ // Hiding reaches only the caller's own popup, as core's does.
+ requestImpl.set('map:hidePopup', (payload, { caller }) => {
+ if (!openPopup || openPopup.owner !== caller) return false
+ settleOpen('closed')
+ return true
+ })
+
+ return api
+}
+
+let api
+
+/**
+ * Let queued microtasks (bus request promises) run. A selection chains several
+ * of them — reading the camera, then deciding the fit — so one tick is not
+ * enough to reach the state a test is about to assert on.
+ */
+const flush = async () => {
+ for (let i = 0; i < 5; i++) await vi.advanceTimersByTimeAsync(0)
+}
+
+/** Make a selection and let its deferred popup open. */
+async function selectAndOpen(feature, label) {
+ AOITool._applySelection(feature, 'search', label)
+ // The camera is read before the popup is armed to wait on `map:moveend`.
+ await flush()
+ api.emit('map:moveend')
+ await flush()
+}
+
+beforeEach(async () => {
+ vi.useFakeTimers()
+ const container = document.createElement('div')
+ container.id = 'toolPanel'
+ document.body.appendChild(container)
+
+ api = makeFakeApi()
+ window.mmgisAPI = api
+ // The controller mints this handle and injects it before make() runs.
+ AOITool.api = api.handleFor('aoi')
+
+ AOITool.make('toolPanel')
+ await flush()
+ api.reset()
+})
+
+afterEach(() => {
+ AOITool.destroy()
+ const container = document.getElementById('toolPanel')
+ if (container) container.remove()
+ delete AOITool.api
+ delete window.mmgisAPI
+ vi.useRealTimers()
+})
+
+describe('AOITool popup requests', () => {
+ test('asks core for the analyze/cancel popup at the feature centroid, once the camera settles', async () => {
+ // A label holding markup goes to core as it was written: core renders
+ // a title as text, so escaping one here would put the escapes
+ // themselves on the card.
+ AOITool._applySelection(SQUARE, 'search', 'Smith & Sons')
+ await flush()
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+
+ api.emit('map:moveend')
+
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ const payload = shows[0].payload
+ expect(payload.latlng).toEqual({ lat: 5, lng: 5 })
+ expect(payload.title).toBe('Smith & Sons')
+ // Labels only: the outcome comes back on the request's promise, so the
+ // plugin names no events for core to broadcast — and no body at all,
+ // the card being the title over its two buttons.
+ expect(payload.primaryAction).toEqual({ label: 'Analyze area' })
+ expect(payload.secondaryAction).toEqual({ label: 'Cancel' })
+ expect('html' in payload).toBe(false)
+
+ // The request must survive a postMessage boundary: data only, no
+ // functions crossing into core.
+ expect(JSON.parse(JSON.stringify(payload))).toEqual(payload)
+ })
+
+ test('lets the next selection replace the open card, keeping the new selection', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ expect(api.hasOpenPopup()).toBe(true)
+ api.reset()
+
+ // The replacement answers the first request with 'closed', which must
+ // not be read as the user abandoning the selection just made. Nothing
+ // is retracted on the way: only a click on empty map dismisses a card,
+ // and the click that picks a boundary is a click on a feature.
+ await selectAndOpen(FAR_SQUARE, 'Alaska')
+
+ expect(api.namesOf('map:hidePopup')).toHaveLength(0)
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ expect(shows[0].payload.title).toBe('Alaska')
+ expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0)
+ expect(api.getSelection()).toMatchObject({ feature: FAR_SQUARE })
+ })
+
+ test('catches the moveend a transitionless fit emits inside its own request', async () => {
+ // An engine with no transition to run ends the camera move inside the
+ // `map:fitBounds` call itself, so the plugin has to be listening
+ // before it asks for the fit — a listener added afterwards hears
+ // nothing and leaves the card to the 1.5s fallback timer.
+ api.requestImpl.set('map:fitBounds', () => {
+ api.emit('map:moveend', { longitude: 5, latitude: 5, zoom: 4 })
+ return true
+ })
+
+ AOITool._applySelection(SQUARE, 'search', 'Alabama')
+ // `flush` advances the clock by nothing, so the fallback timer cannot
+ // be what opened this.
+ await flush()
+
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ expect(shows[0].payload.latlng).toEqual({ lat: 5, lng: 5 })
+ expect(api.listenerCount('map:moveend')).toBe(0)
+ })
+})
+
+describe('AOITool popup outcomes', () => {
+ test('a primary press hands the selected feature to the analysis consumers', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.reset()
+
+ api.closePopup('primary')
+ await flush()
+
+ const ready = api.emitsOf('plugin:aoi:analysisAOIReady')
+ expect(ready).toHaveLength(1)
+ expect(ready[0].data).toEqual({ feature: SQUARE })
+ // Analyzing keeps the selection; only cancelling clears it.
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE, source: 'search' })
+ })
+
+ test.each(['secondary', 'dismiss'])(
+ 'a %s close clears the selection and its highlight',
+ async (action) => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.reset()
+
+ api.closePopup(action)
+ await flush()
+
+ expect(api.namesOf('map:removeLayer').map((r) => r.payload)).toContainEqual({
+ id: 'aoi:selection',
+ })
+ expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(1)
+ expect(api.getSelection()).toBeNull()
+ }
+ )
+
+ test('a popup that closed on its own leaves the selection alone', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.reset()
+
+ api.closePopup('closed')
+ await flush()
+
+ expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0)
+ expect(api.emitsOf('plugin:aoi:analysisAOIReady')).toHaveLength(0)
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ })
+
+ test('a rejected popup request is reported and keeps the selection', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { })
+ api.requestImpl.set('map:showPopup', () => {
+ throw new Error('invalid request')
+ })
+
+ AOITool._applySelection(SQUARE, 'search', 'Alabama')
+ await flush()
+ api.emit('map:moveend')
+ await flush()
+
+ // Named, because a selection warns from four other places: any of
+ // them would satisfy a bare "warned about something".
+ expect(warn).toHaveBeenCalledWith('[AOI] showPopup failed', expect.any(Error))
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ })
+})
+
+describe('AOITool popup lifecycle', () => {
+ // Closing the tool and unloading it both reach the plugin through
+ // `destroy()`, and that is the whole of the teardown contract: nothing of
+ // the selection outlives the tool. Hiding the tool does not reach it — the
+ // instance is kept and the card stays up.
+ test('destroy clears the selection, its highlight and the popup', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ expect(api.hasOpenPopup()).toBe(true)
+ api.reset()
+
+ AOITool.destroy()
+ await flush()
+
+ expect(api.namesOf('map:hidePopup')).toHaveLength(1)
+ expect(api.hasOpenPopup()).toBe(false)
+ expect(
+ api.namesOf('map:removeLayer').map((r) => r.payload.id)
+ ).toContain('aoi:selection')
+ expect(AOITool._state.currentAOI).toBeNull()
+ expect(api.listenerCount('map:featureClick')).toBe(0)
+ })
+
+ test('destroy disarms a show already waiting on the camera', async () => {
+ AOITool._applySelection(SQUARE, 'search', 'Alabama')
+ // Far enough in that the show is armed: the camera has been read and
+ // the fit asked for, so a `map:moveend` listener and the fallback timer
+ // are both standing.
+ await flush()
+ expect(api.listenerCount('map:moveend')).toBe(1)
+ api.reset()
+
+ AOITool.destroy()
+
+ expect(api.listenerCount('map:moveend')).toBe(0)
+ api.emit('map:moveend')
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+ })
+
+ test('a superseding selection disarms the show already waiting on the camera', async () => {
+ AOITool._applySelection(SQUARE, 'search', 'Alabama')
+ await flush()
+ expect(api.listenerCount('map:moveend')).toBe(1)
+
+ AOITool._applySelection(FAR_SQUARE, 'search', 'Alaska')
+ await flush()
+ // The superseded show let go of its listener and its timer; only the
+ // current one is armed.
+ expect(api.listenerCount('map:moveend')).toBe(1)
+
+ api.emit('map:moveend')
+ await vi.advanceTimersByTimeAsync(2000)
+
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ expect(shows[0].payload.title).toBe('Alaska')
+ })
+
+ test('a camera step that fails leaves nothing pending', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { })
+ // A view missing a corner: deciding the fit throws on it, so the chain
+ // ends after the show was already marked pending and before anything
+ // was armed to settle it.
+ api.requestImpl.set('map:getBounds', () => ({
+ northEast: { lat: -30, lng: -30 },
+ }))
+
+ AOITool._applySelection(SQUARE, 'search', 'Alabama')
+ await flush()
+
+ expect(warn).toHaveBeenCalled()
+ // Nothing was armed to open this selection's popup, so neither a
+ // moveend nor the fallback timer can produce one.
+ api.emit('map:moveend')
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+ })
+
+})
+
+// Picking a shape arms a session; it does not choose an area. What these pin
+// is where along a session the previous selection is actually given up, and
+// what the user is left with when the session ends without a drawing.
+describe('AOITool drawing sessions', () => {
+ const VERTEX = { shape: 'polygon', vertices: [{ lat: 1, lng: 1 }] }
+
+ test('arming a session retracts the card and keeps the selection', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ expect(api.hasOpenPopup()).toBe(true)
+ api.reset()
+
+ api.emit('map:drawstart', { shape: 'polygon' })
+ await flush()
+
+ // The card cannot stay: it would sit over the map for the whole
+ // session, holding the Escape and Enter the session needs and offering
+ // to analyze the area being replaced.
+ expect(api.hasOpenPopup()).toBe(false)
+ expect(api.namesOf('map:hidePopup')).toHaveLength(1)
+ // The selection can, and must — nothing has replaced it yet, and a
+ // selection dropped here has no undo.
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(0)
+ })
+
+ test('backing out before any vertex puts the card back', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ // The camera was fitted to the selection, so its centroid is in view
+ // and the card goes back on it.
+ api.requestImpl.set('map:getBounds', () => ({
+ southWest: { lat: 0, lng: 0 },
+ northEast: { lat: 10, lng: 10 },
+ }))
+ api.emit('map:drawstart', { shape: 'polygon' })
+ await flush()
+ api.reset()
+
+ api.emit('map:drawcancel', { shape: 'polygon' })
+ await flush()
+
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ expect(shows[0].payload.title).toBe('Alabama')
+ expect(shows[0].payload.latlng).toEqual({ lat: 5, lng: 5 })
+ expect(api.hasOpenPopup()).toBe(true)
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ })
+
+ test('re-arming across the camera read leaves the card down', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.emit('map:drawstart', { shape: 'polygon' })
+ await flush()
+ api.reset()
+
+ // Backing out reads the camera before putting the card back, and the
+ // user picks another shape across that hop. The card belongs to the
+ // selection the new session is about to replace, so it stays down.
+ api.emit('map:drawcancel', { shape: 'polygon' })
+ api.emit('map:drawstart', { shape: 'rectangle' })
+ await flush()
+
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+ expect(api.hasOpenPopup()).toBe(false)
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ })
+
+ test('the first vertex is where the previous selection goes', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.emit('map:drawstart', { shape: 'polygon' })
+ await flush()
+ api.reset()
+
+ api.emit('map:drawvertex', VERTEX)
+ await flush()
+
+ expect(api.getSelection()).toBeNull()
+ expect(api.emitsOf('plugin:aoi:drawingCleared')).toHaveLength(1)
+ expect(
+ api.namesOf('map:removeLayer').map((r) => r.payload.id)
+ ).toContain('aoi:selection')
+
+ // Backing out from here has nothing left to put back.
+ api.reset()
+ api.emit('map:drawcancel', { shape: 'polygon' })
+ await flush()
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+ })
+
+ test('a finished drawing gets its own card', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ api.emit('map:drawstart', { shape: 'polygon' })
+ api.emit('map:drawvertex', VERTEX)
+ await flush()
+ api.reset()
+
+ api.emit('map:drawcomplete', { feature: FAR_SQUARE })
+ await flush()
+ api.emit('map:moveend')
+ await flush()
+
+ const shows = api.namesOf('map:showPopup')
+ expect(shows).toHaveLength(1)
+ expect(shows[0].payload.title).toBe('Drawn area')
+ expect(shows[0].payload.latlng).toEqual({ lat: 25, lng: 25 })
+ expect(api.getSelection()).toMatchObject({ feature: FAR_SQUARE, source: 'draw' })
+ })
+
+ // Both engines end the live session inside `enableDrawing` before starting
+ // the new one, and end it without a cancel of its own, so a shape switch
+ // reaches the plugin as a `drawstart` alone.
+ test('switching shape mid-session keeps the session and its new shape', async () => {
+ api.requestImpl.set('map:enableDrawing', ({ shape }) => {
+ api.emit('map:drawstart', { shape })
+ return true
+ })
+ await selectAndOpen(SQUARE, 'Alabama')
+
+ AOITool._onDrawShapeChange('polygon')
+ await flush()
+ expect(AOITool._state).toMatchObject({ isDrawing: true, drawShape: 'polygon' })
+ api.reset()
+
+ AOITool._onDrawShapeChange('rectangle')
+ await flush()
+
+ // A live session on the new shape: the panel needs both, or it falls
+ // back to the shape picker while a rectangle session is running.
+ expect(AOITool._state).toMatchObject({ isDrawing: true, drawShape: 'rectangle' })
+ // Nothing was backed out of, so the suspended card stays suspended
+ // instead of coming back only to be retracted again.
+ expect(api.namesOf('map:showPopup')).toHaveLength(0)
+ expect(api.getSelection()).toMatchObject({ feature: SQUARE })
+ })
+})
+
+// One popup slot, and core decides whose popup a `map:hidePopup` reaches. AOI
+// asks whenever it is done with a popup and lets core sort out whether there
+// is one of its own to close — so what these pin is the outcome, not whether
+// AOI worked out for itself that it should stay quiet.
+describe('AOITool popup ownership', () => {
+ // The stamp is the whole mechanism, and it comes from asking through the
+ // injected handle: a popup opened on the bare bus carries no address, and
+ // would be one AOI could never retract.
+ test('asks for its popup through its own handle', async () => {
+ await selectAndOpen(SQUARE, 'Alabama')
+ AOITool.destroy()
+
+ expect(api.namesOf('map:showPopup')[0].caller).toBe('aoi')
+ expect(api.namesOf('map:hidePopup')[0].caller).toBe('aoi')
+ })
+})
diff --git a/tests/unit/aoiDrawKeys.spec.js b/tests/unit/aoiDrawKeys.spec.js
index 184ed9565..37cd5b248 100644
--- a/tests/unit/aoiDrawKeys.spec.js
+++ b/tests/unit/aoiDrawKeys.spec.js
@@ -28,22 +28,51 @@ function appendTo(body, tag, attributes = {}) {
return el
}
+/**
+ * Start a session the way `map:drawstart` does, and drop the bookkeeping that
+ * comes with it — starting one retracts the previous selection's card — so
+ * `requests` holds only what the keys asked for.
+ */
+function startSession() {
+ AOITool._onDrawStart()
+ requests.length = 0
+}
+
beforeEach(() => {
requests = []
finishSucceeds = true
window.mmgisAPI = {
request: (name) => {
requests.push(name)
- return Promise.resolve(
- name === 'map:finishDrawing' ? finishSucceeds : true
- )
+ if (name === 'map:finishDrawing') {
+ return Promise.resolve(finishSucceeds)
+ }
+ // There is no camera behind these specs, and "no view" is an
+ // answer the popup anchoring understands — `true` is not.
+ if (name === 'map:getBounds') return Promise.resolve(null)
+ return Promise.resolve(true)
},
}
+ // These specs drive the session directly rather than through `make()`, so
+ // hand the tool the handle the controller would have injected — every
+ // request it makes goes through that.
+ AOITool.api = {
+ // As `mintHandle` does: subscribing hands back the disposer for it.
+ on: () => () => { },
+ emit: () => { },
+ provide: () => () => { },
+ request: (name, data) => window.mmgisAPI.request(name, data),
+ release: () => { },
+ }
})
afterEach(() => {
AOITool._removeDrawKeys()
AOITool._state.isDrawing = false
+ // The module is shared across these specs, so a selection one of them
+ // completed must not be one the next one's session suspends.
+ AOITool._state.currentAOI = null
+ AOITool._suspendedAOI = null
document.body.innerHTML = ''
delete AOITool.api
delete window.mmgisAPI
@@ -51,13 +80,13 @@ afterEach(() => {
test.describe('AOI draw-session keys', () => {
test('Escape cancels the drawing from anywhere on the page', () => {
- AOITool._onDrawStart()
+ startSession()
press('Escape')
expect(requests).toEqual(['map:disableDrawing'])
})
test('Enter finishes the drawing from anywhere on the page', () => {
- AOITool._onDrawStart()
+ startSession()
press('Enter')
expect(requests).toEqual(['map:finishDrawing'])
})
@@ -67,7 +96,7 @@ test.describe('AOI draw-session keys', () => {
// terra-draw hears are the two halves of one press, and must not read as
// two finishes.
test('both keys work with the map element focused', () => {
- AOITool._onDrawStart()
+ startSession()
const canvas = appendTo(document.body, 'canvas')
press('Enter', canvas)
release('Enter', canvas)
@@ -79,7 +108,7 @@ test.describe('AOI draw-session keys', () => {
// Clicking a panel control moves focus off the map, which is exactly where
// terra-draw stops hearing anything.
test('both keys work with a panel control focused', () => {
- AOITool._onDrawStart()
+ startSession()
const button = appendTo(document.body, 'button')
press('Enter', button)
press('Escape', button)
@@ -87,7 +116,7 @@ test.describe('AOI draw-session keys', () => {
})
test('leaves the keys to whatever field they were typed in', () => {
- AOITool._onDrawStart()
+ startSession()
// jsdom parses contenteditable but never sets isContentEditable, so
// stand in for the flag a browser would have raised here.
const editable = appendTo(document.body, 'div', { contenteditable: 'true' })
@@ -108,7 +137,7 @@ test.describe('AOI draw-session keys', () => {
// must survive an Escape aimed at one of those — including one aimed at a
// control nested inside it.
test('leaves the keys to a component that closes on Escape', () => {
- AOITool._onDrawStart()
+ startSession()
for (const role of ['dialog', 'menu', 'listbox', 'combobox']) {
const owner = appendTo(document.body, 'div', { role })
const nested = appendTo(owner, 'button')
@@ -120,7 +149,7 @@ test.describe('AOI draw-session keys', () => {
})
test('ignores a key held down long enough to repeat', () => {
- AOITool._onDrawStart()
+ startSession()
press('Enter', document.body, { repeat: true })
press('Escape', document.body, { repeat: true })
expect(requests).toEqual([])
@@ -131,7 +160,7 @@ test.describe('AOI draw-session keys', () => {
// stays in drawing state with the keys still live.
test('keeps drawing when the shape has too few vertices to finish', async () => {
finishSucceeds = false
- AOITool._onDrawStart()
+ startSession()
press('Enter')
expect(await window.mmgisAPI.request('map:finishDrawing')).toBe(false)
expect(AOITool._state.isDrawing).toBe(true)
@@ -144,7 +173,7 @@ test.describe('AOI draw-session keys', () => {
})
test('stops listening once the drawing completes', () => {
- AOITool._onDrawStart()
+ startSession()
AOITool._onDrawComplete({
feature: {
type: 'Feature',
@@ -162,7 +191,7 @@ test.describe('AOI draw-session keys', () => {
})
test('stops listening once the drawing is cancelled', () => {
- AOITool._onDrawStart()
+ startSession()
AOITool._onDrawCancelEvent()
requests = []
press('Escape')
@@ -170,7 +199,7 @@ test.describe('AOI draw-session keys', () => {
})
test('stops listening when the tool goes away', () => {
- AOITool._onDrawStart()
+ startSession()
AOITool.destroy()
requests = []
press('Escape')
@@ -178,33 +207,26 @@ test.describe('AOI draw-session keys', () => {
})
test('installs a single listener however often a session starts', () => {
- AOITool._onDrawStart()
- AOITool._onDrawStart()
+ startSession()
+ startSession()
press('Escape')
expect(requests).toEqual(['map:disableDrawing'])
})
// The keys are only ever armed by the engine's drawstart reaching the
- // plugin, so drive the session the way the bus does: through the tool the
- // panel actually makes.
+ // plugin, so drive the session the way the bus does: through the handle
+ // the tool the panel actually makes subscribes on.
test('a drawstart delivered over the bus arms the keys', () => {
const handlers = {}
- window.mmgisAPI.on = (event, handler) => {
+ AOITool.api.on = (event, handler) => {
handlers[event] = handler
return () => delete handlers[event]
}
- // The controller injects the plugin-scoped handle before make() runs.
- AOITool.api = {
- on: () => () => { },
- emit: () => { },
- provide: () => () => { },
- request: () => Promise.resolve(null),
- release: () => { },
- }
appendTo(document.body, 'div', { id: 'toolPanel' })
AOITool.make('toolPanel')
handlers['map:drawstart']({ shape: 'polygon' })
+ requests.length = 0
press('Escape')
expect(requests).toEqual(['map:disableDrawing'])
diff --git a/tests/unit/aoiSelectionCamera.spec.js b/tests/unit/aoiSelectionCamera.spec.js
index 2d6d2d761..35bba7ea3 100644
--- a/tests/unit/aoiSelectionCamera.spec.js
+++ b/tests/unit/aoiSelectionCamera.spec.js
@@ -1,7 +1,9 @@
import { test, expect, vi, beforeEach, afterEach } from 'vitest'
import {
+ featureBounds,
+ featureCentroid,
selectionFitBounds,
- selectionTooltipAnchor,
+ selectionPopupAnchor,
} from '../../src/essence/Tools/AOI/aoiHelpers.ts'
import AOITool from '../../src/essence/Tools/AOI/AOITool.js'
@@ -29,6 +31,12 @@ const squareFeature = (w, s, e, n) => ({
},
})
+const geometry = (type, coordinates) => ({
+ type: 'Feature',
+ properties: {},
+ geometry: { type, coordinates },
+})
+
test.describe('selectionFitBounds', () => {
test('returns null when the selection is fully inside the view', () => {
expect(selectionFitBounds([-98, 32, -92, 38], view)).toBeNull()
@@ -113,26 +121,75 @@ test.describe('selectionFitBounds', () => {
})
})
-test.describe('selectionTooltipAnchor', () => {
+test.describe('featureCentroid', () => {
+ test('averages a polygon ring, counting its repeated closing vertex once', () => {
+ expect(featureCentroid(squareFeature(0, 0, 10, 20))).toEqual([5, 10])
+ })
+
+ // `drawShapes: linestring` is a supported mission config, and the popup
+ // card holds the only Analyze and Cancel a selection has — no centroid, no
+ // card, and the drawn line cannot be analyzed or cleared.
+ test('averages a linestring, which is what a drawn line selects on', () => {
+ expect(
+ featureCentroid(geometry('LineString', [[0, 0], [10, 4], [20, 8]]))
+ ).toEqual([10, 4])
+ })
+
+ test('has no centroid for a geometry with no vertices to average', () => {
+ expect(featureCentroid(geometry('LineString', []))).toBeNull()
+ expect(featureCentroid({ type: 'Feature', properties: {} })).toBeNull()
+ })
+})
+
+test.describe('featureBounds', () => {
+ test('envelopes a polygon ring', () => {
+ expect(featureBounds(squareFeature(0, 0, 10, 20))).toEqual([0, 0, 10, 20])
+ })
+
+ // The bounds decide whether the camera is asked to frame the selection and
+ // whether the anchor gets a view to fall back on, so every geometry the
+ // centroid supports has to reach that path too.
+ test('envelopes the lines and the point, as the centroid does', () => {
+ expect(
+ featureBounds(geometry('LineString', [[0, 0], [10, 4], [20, 8]]))
+ ).toEqual([0, 0, 20, 8])
+ expect(
+ featureBounds(
+ geometry('MultiLineString', [
+ [[0, 0], [2, 0]],
+ [[-1, 4], [2, 6]],
+ ])
+ )
+ ).toEqual([-1, 0, 2, 6])
+ expect(featureBounds(geometry('Point', [3, 7]))).toEqual([3, 7, 3, 7])
+ })
+
+ test('has no bounds for a geometry with no vertices', () => {
+ expect(featureBounds(geometry('LineString', []))).toBeNull()
+ expect(featureBounds({ type: 'Feature', properties: {} })).toBeNull()
+ })
+})
+
+test.describe('selectionPopupAnchor', () => {
const centroid = { lat: 35, lng: -95 }
test('keeps a centroid that is inside the view', () => {
- expect(selectionTooltipAnchor(centroid, view)).toEqual(centroid)
+ expect(selectionPopupAnchor(centroid, view)).toEqual(centroid)
})
test('keeps the centroid when no view is supplied (camera was fitted)', () => {
- expect(selectionTooltipAnchor(centroid)).toEqual(centroid)
+ expect(selectionPopupAnchor(centroid)).toEqual(centroid)
})
test('falls back to the view centre for an off-screen centroid (Alaska)', () => {
- expect(selectionTooltipAnchor({ lat: 58.4, lng: -139.3 }, view)).toEqual({
+ expect(selectionPopupAnchor({ lat: 58.4, lng: -139.3 }, view)).toEqual({
lat: 35,
lng: -95,
})
})
test('falls back for a centroid off-screen in latitude only', () => {
- expect(selectionTooltipAnchor({ lat: 5, lng: -95 }, view)).toEqual({
+ expect(selectionPopupAnchor({ lat: 5, lng: -95 }, view)).toEqual({
lat: 35,
lng: -95,
})
@@ -143,7 +200,7 @@ test.describe('selectionTooltipAnchor', () => {
southWest: { lat: 30, lng: 0 },
northEast: { lat: 40, lng: 400 },
}
- expect(selectionTooltipAnchor({ lat: 80, lng: 5 }, overwide)).toEqual({
+ expect(selectionPopupAnchor({ lat: 80, lng: 5 }, overwide)).toEqual({
lat: 35,
lng: 200,
})
@@ -154,7 +211,7 @@ test.describe('selectionTooltipAnchor', () => {
southWest: { lat: 30, lng: 170 },
northEast: { lat: 40, lng: -170 },
}
- expect(selectionTooltipAnchor({ lat: 58.4, lng: -139.3 }, wrapped)).toEqual({
+ expect(selectionPopupAnchor({ lat: 58.4, lng: -139.3 }, wrapped)).toEqual({
lat: 35,
lng: 180,
})
@@ -162,17 +219,41 @@ test.describe('selectionTooltipAnchor', () => {
})
test.describe('AOITool._applySelection camera behavior', () => {
+ // Every subscription the tool made through its handle, in order, each with
+ // the disposer the handle handed back — unhooking is calling that disposer,
+ // so that is what these specs watch.
+ let subs = []
const mockApi = (currentView) => {
const calls = []
+ subs = []
window.mmgisAPI = {
- request: vi.fn((name, payload) => {
- calls.push({ name, payload })
+ // The handle below adds a third argument naming who asked, the
+ // way the real one stamps a request with its plugin's address.
+ // Recording it unwrapped is what keeps `calls` reading as a plain
+ // caller.
+ request: vi.fn((name, payload, options) => {
+ calls.push({ name, payload, caller: options?.caller })
return Promise.resolve(
name === 'map:getBounds' ? currentView : undefined
)
}),
- on: vi.fn(),
- off: vi.fn(),
+ }
+ // These specs drive `_applySelection` on its own rather than through
+ // `make()`, so hand the tool the handle the controller would have
+ // injected. Every request and subscription goes through it: that is
+ // what stamps a request with the plugin's address, and what makes a
+ // subscription hand back its own disposer.
+ AOITool.api = {
+ on: (event, handler) => {
+ const off = vi.fn()
+ subs.push({ event, handler, off })
+ return off
+ },
+ emit: () => { },
+ provide: () => () => { },
+ request: (name, data) =>
+ window.mmgisAPI.request(name, data, { caller: 'aoi' }),
+ release: () => { },
}
return calls
}
@@ -189,22 +270,20 @@ test.describe('AOITool._applySelection camera behavior', () => {
vi.clearAllTimers()
vi.useRealTimers()
delete window.mmgisAPI
+ delete AOITool.api
AOITool._state.currentAOI = null
})
- test('makes no camera call and mounts the tooltip promptly when the selection is in view', async () => {
+ test('makes no camera call and opens the popup promptly when the selection is in view', async () => {
const calls = mockApi(view)
AOITool._applySelection(squareFeature(-98, 32, -92, 38), 'draw', 'In view')
await flush()
expect(names(calls)).not.toContain('map:fitBounds')
- const overlay = calls.find((c) => c.name === 'map:addOverlay')
+ const popup = calls.find((c) => c.name === 'map:showPopup')
// Anchored at the selection's own centroid — the view never moved, but
// the centroid was already on-screen.
- expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -95 })
- expect(window.mmgisAPI.on).not.toHaveBeenCalledWith(
- 'map:moveend',
- expect.anything()
- )
+ expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -95 })
+ expect(subs).toHaveLength(0)
})
test('forwards the selection extent to map:fitBounds when it overflows the view', async () => {
@@ -222,18 +301,19 @@ test.describe('AOITool._applySelection camera behavior', () => {
{ lat: 32, lng: -98 },
{ lat: 38, lng: -80 },
],
- })
+ }),
+ { caller: 'aoi' }
)
- // The tooltip waits for the camera; this mock never fires moveend, so
+ // The popup waits for the camera; this mock never fires moveend, so
// the fallback timer mounts it — at the centroid, now framed.
- expect(names(calls)).not.toContain('map:addOverlay')
+ expect(names(calls)).not.toContain('map:showPopup')
vi.advanceTimersByTime(1600)
await flush()
- const overlay = calls.find((c) => c.name === 'map:addOverlay')
- expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -89 })
+ const popup = calls.find((c) => c.name === 'map:showPopup')
+ expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -89 })
})
- test('mounts the tooltip on moveend and unhooks the handler', async () => {
+ test('opens the popup on moveend and unhooks the handler', async () => {
const calls = mockApi(view)
AOITool._applySelection(
squareFeature(-98, 32, -80, 38),
@@ -241,29 +321,28 @@ test.describe('AOITool._applySelection camera behavior', () => {
'Beyond view'
)
await flush()
- const [event, onMoveend] = window.mmgisAPI.on.mock.calls[0]
+ const { event, handler: onMoveend, off: offMoveend } = subs[0]
expect(event).toBe('map:moveend')
- expect(names(calls)).not.toContain('map:addOverlay')
+ expect(names(calls)).not.toContain('map:showPopup')
// Map_ re-emits moveend with the engine's view state, so the handler
- // must drop that payload rather than pass it on as a ViewBounds.
- onMoveend({ longitude: -89, latitude: 35, zoom: 6 })
+ // must drop that payload rather than pass it on as a ViewBounds. The
+ // numbers in it are nowhere near the selection's centroid, so a
+ // handler that read the card's anchor off them would be caught here.
+ onMoveend({ longitude: 12, latitude: -4, zoom: 6 })
// At the centroid: the camera has framed the selection, so no anchor
// fallback applies.
expect(
- calls.find((c) => c.name === 'map:addOverlay')?.payload.latlng
+ calls.find((c) => c.name === 'map:showPopup')?.payload.latlng
).toEqual({ lat: 35, lng: -89 })
- expect(window.mmgisAPI.off).toHaveBeenCalledWith(
- 'map:moveend',
- onMoveend
- )
- // The fallback timer is disarmed, so it cannot mount a second tooltip.
+ expect(offMoveend).toHaveBeenCalled()
+ // The fallback timer is disarmed, so it cannot open a second popup.
vi.advanceTimersByTime(1600)
await flush()
- expect(calls.filter((c) => c.name === 'map:addOverlay')).toHaveLength(1)
+ expect(calls.filter((c) => c.name === 'map:showPopup')).toHaveLength(1)
})
- test('anchors the tooltip inside the view when fitBounds is rejected', async () => {
+ test('anchors the popup inside the view when fitBounds is rejected', async () => {
const calls = mockApi(view)
const request = window.mmgisAPI.request
window.mmgisAPI.request = vi.fn((name, payload) =>
@@ -278,12 +357,52 @@ test.describe('AOITool._applySelection camera behavior', () => {
)
await flush()
// The camera never moved, so the off-screen centroid (-89) would strand
- // the tooltip; it falls back to the centre of the unchanged view.
- const overlay = calls.find((c) => c.name === 'map:addOverlay')
- expect(overlay?.payload.latlng).toEqual({ lat: 35, lng: -95 })
+ // the popup; it falls back to the centre of the unchanged view.
+ const popup = calls.find((c) => c.name === 'map:showPopup')
+ expect(popup?.payload.latlng).toEqual({ lat: 35, lng: -95 })
+ })
+
+ test('drops a fitBounds rejection that lands after a superseding selection', async () => {
+ const calls = mockApi(view)
+ const request = window.mmgisAPI.request
+ // Hold each fit open, so the first selection's rejection can be made to
+ // arrive after the second selection has already replaced it.
+ const rejectFit = []
+ window.mmgisAPI.request = vi.fn((name, payload, options) => {
+ if (name !== 'map:fitBounds') return request(name, payload, options)
+ calls.push({ name, payload, caller: options?.caller })
+ return new Promise((_resolve, reject) => rejectFit.push(reject))
+ })
+
+ AOITool._applySelection(squareFeature(-98, 32, -80, 38), 'search', 'First')
+ await flush()
+ AOITool._applySelection(squareFeature(-98, 20, -80, 45), 'search', 'Second')
+ await flush()
+
+ const { handler: secondMoveend, off: secondOff } = subs[1]
+
+ rejectFit[0](new Error('nope'))
+ await flush()
+
+ // The stale show stays shut: its selection is gone from the map and
+ // from `currentAOI`, so its popup would offer to analyze an area the
+ // user has already replaced.
+ expect(names(calls)).not.toContain('map:showPopup')
+ // And it leaves the current selection's show armed. Disarming that one
+ // is the worse half of the failure: the popup the user is waiting for
+ // never opens at all.
+ expect(secondOff).not.toHaveBeenCalled()
+
+ // A view state nowhere near the selection's centroid, so a handler
+ // that forwarded it as the anchor is caught here.
+ secondMoveend({ longitude: 12, latitude: -4, zoom: 6 })
+ const popups = calls.filter((c) => c.name === 'map:showPopup')
+ expect(popups).toHaveLength(1)
+ expect(popups[0].payload.title).toBe('Second')
+ expect(popups[0].payload.latlng).toEqual({ lat: 32.5, lng: -89 })
})
- test('anchors the tooltip inside the view for an unframeable selection (Alaska)', async () => {
+ test('anchors the popup inside the view for an unframeable selection (Alaska)', async () => {
const calls = mockApi(view)
// A ring straddling ±180, as Alaska's MultiPolygon does.
AOITool._applySelection(
@@ -293,8 +412,8 @@ test.describe('AOITool._applySelection camera behavior', () => {
)
await flush()
expect(names(calls)).not.toContain('map:fitBounds')
- const overlay = calls.find((c) => c.name === 'map:addOverlay')
- expect(overlay).toBeDefined()
- expect(overlay.payload.latlng).toEqual({ lat: 35, lng: -95 })
+ const popup = calls.find((c) => c.name === 'map:showPopup')
+ expect(popup).toBeDefined()
+ expect(popup.payload.latlng).toEqual({ lat: 35, lng: -95 })
})
})