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 }) }) })