diff --git a/mission-profiles/full-demo.json b/mission-profiles/full-demo.json index bee6b2d38..eaa1161cc 100644 --- a/mission-profiles/full-demo.json +++ b/mission-profiles/full-demo.json @@ -21,6 +21,7 @@ "on": [ "Title", "LayerManager", + "AddTempLayer", "AOI", "Card", "Chart", @@ -210,7 +211,8 @@ "defaultState": "expanded" }, "panelTools": [ - "ShareExport" + "ShareExport", + "AddTempLayer" ], "id": "float-share" }, diff --git a/mission-profiles/generated/full-demo-mission.json b/mission-profiles/generated/full-demo-mission.json index 73838da09..7ea998a94 100644 --- a/mission-profiles/generated/full-demo-mission.json +++ b/mission-profiles/generated/full-demo-mission.json @@ -161,7 +161,8 @@ "defaultState": "expanded" }, "panelTools": [ - "ShareExport" + "ShareExport", + "AddTempLayer" ], "id": "float-share" }, @@ -316,6 +317,27 @@ "justification": "left" } }, + { + "name": "AddTempLayer", + "icon": "add", + "js": "AddTempLayerTool", + "on": true, + "variables": {}, + "metadata": { + "icon": "add", + "compatiblePositions": [ + "float-top-left", + "float-top-center", + "float-top-right", + "float-bottom-left", + "float-bottom-center", + "float-bottom-right" + ], + "preferredPosition": "float-top-right", + "startHidden": true, + "modernLayoutSupport": true + } + }, { "name": "Chart", "icon": "chart-bar", diff --git a/src/essence/Tools/AddTempLayer/config.json b/src/essence/Tools/AddTempLayer/config.json index 92d33220a..0766cf6cc 100644 --- a/src/essence/Tools/AddTempLayer/config.json +++ b/src/essence/Tools/AddTempLayer/config.json @@ -1,4 +1,7 @@ { + "defaults": { + "variables": {} + }, "defaultIcon": "add", "description": "Add an external layer (WMS/WMTS/XYZ/GeoJSON) to the map for the current session.", "descriptionFull": { diff --git a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx index 80b802ca1..95b809306 100644 --- a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx +++ b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx @@ -13,6 +13,8 @@ import { setColormap, setRescale, zoomToLayer, + compareLayer, + showAddLayer, } from './adapters/handlers' import { mmgisGetLayerBounds } from '../_shared/adapters/mmgisAPI' @@ -76,6 +78,8 @@ export function MMGISLayerManagerAdapter() { onRescaleChange={(id, mn, mx) => { report('setRescale', setRescale(id, mn, mx, refresh)) }} onZoomToLayer={(id) => { report('zoomToLayer', zoomToLayer(id)) }} canZoomToLayer={canZoomToLayer} + onCompareLayer={compareLayer} + onAddLayer={showAddLayer} /> ) } diff --git a/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx b/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx index eb08c7c7b..9387b71ff 100644 --- a/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx +++ b/src/essence/Tools/LayerManager/__tests__/LayerManagerPanel.spec.tsx @@ -64,6 +64,15 @@ const titlesIn = (container: HTMLElement) => const rampButtonsIn = (container: HTMLElement) => container.querySelectorAll('[title="Change color ramp"]') +/** Open a row's kebab and read its items out of the portal they render into. */ +const openMenu = async (container: HTMLElement) => { + await click(container.querySelector('[title="More options"]')!) + return Array.from(document.body.querySelectorAll('[role="menuitem"]')) +} + +const menuItem = (items: Element[], label: string) => + items.find((el) => el.textContent?.includes(label)) + beforeEach(() => { // Deleted rather than stubbed, so calling through either one throws. delete (window as { mmgisAPI?: unknown }).mmgisAPI @@ -103,6 +112,28 @@ describe('LayerManagerPanel without a host', () => { await unmount() }) + test('omits the add-layer button unless the host handles it', async () => { + const { container, unmount } = await mount( + , + ) + expect(container.querySelector('.blocks-layer-manager__add-layer')).toBeNull() + await unmount() + }) + + test('reports add-layer clicks through its callback', async () => { + const onAddLayer = vi.fn() + const { container, unmount } = await mount( + , + ) + + const button = container.querySelector('.blocks-layer-manager__add-layer')! + expect(button.textContent).toContain('Add layer from URL') + await click(button) + + expect(onAddLayer).toHaveBeenCalledTimes(1) + await unmount() + }) + test('reports visibility changes through its callback', async () => { const onVisibilityChange = vi.fn() const { container, unmount } = await mount( @@ -154,6 +185,41 @@ describe('LayerManagerPanel without a host', () => { await unmount() }) + test('offers the comparison hand-off only when the host wires one', async () => { + const onCompareLayer = vi.fn() + const wired = await mount( + , + ) + await click(menuItem(await openMenu(wired.container), 'Compare layer')!) + expect(onCompareLayer).toHaveBeenCalledWith(GRADIENT_LAYER.id) + await wired.unmount() + + const unwired = await mount() + expect(menuItem(await openMenu(unwired.container), 'Compare layer')) + .toBeUndefined() + await unwired.unmount() + }) + + test('holds the comparison hand-off shut for a layer that is switched off', async () => { + const onCompareLayer = vi.fn() + const { container, unmount } = await mount( + , + ) + + const item = menuItem(await openMenu(container), 'Compare layer')! + expect(item.getAttribute('aria-disabled')).toBe('true') + expect(item.getAttribute('title')).toBe('Turn this layer on to compare it') + await click(item) + expect(onCompareLayer).not.toHaveBeenCalled() + await unmount() + }) + test('renders without any callbacks wired', async () => { const { container, unmount } = await mount( , diff --git a/src/essence/Tools/LayerManager/__tests__/handlers.spec.js b/src/essence/Tools/LayerManager/__tests__/handlers.spec.js index a1e56e17b..241163e09 100644 --- a/src/essence/Tools/LayerManager/__tests__/handlers.spec.js +++ b/src/essence/Tools/LayerManager/__tests__/handlers.spec.js @@ -5,6 +5,8 @@ import { setColormap, setRescale, zoomToLayer, + compareLayer, + showAddLayer, } from '../adapters/handlers.ts' import { ZOOM_TO_LAYER_PADDING, @@ -29,6 +31,9 @@ const setupMock = (responses = {}, emitCalls = []) => { return { emitCalls, requests } } +// showAddLayer fires its request without returning it. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + const EDITABLE = { hasColormap: true, canChangeColormap: true } // What an image layer reports: a ramp to show, but nothing to change. const READ_ONLY = { hasColormap: true, canChangeColormap: false } @@ -260,4 +265,40 @@ test.describe('handlers', () => { expect(requests).toHaveLength(0) warn.mockRestore() }) + + test('compareLayer announces the layer on the bus', () => { + const { emitCalls, requests } = setupMock() + compareLayer('layerA') + + expect(emitCalls).toEqual([ + { + event: 'plugin:comparison:startWithLayer', + payload: { layerId: 'layerA' }, + }, + ]) + expect(requests).toHaveLength(0) + }) + + test('showAddLayer commands the layout to reveal the form', async () => { + const { emitCalls, requests } = setupMock({ + 'plugins:show': { ok: true, state: 'visible', changed: true }, + }) + showAddLayer() + await flush() + + expect(requests).toEqual([ + { name: 'plugins:show', params: { pluginId: 'AddTempLayerTool' } }, + ]) + expect(emitCalls).toHaveLength(0) + }) + + test('showAddLayer logs a refusal instead of dropping it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + setupMock({ 'plugins:show': { ok: false, reason: 'not-found' } }) + showAddLayer() + await flush() + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('not-found')) + warn.mockRestore() + }) }) diff --git a/src/essence/Tools/LayerManager/adapters/handlers.ts b/src/essence/Tools/LayerManager/adapters/handlers.ts index 45e46b471..c349bc94a 100644 --- a/src/essence/Tools/LayerManager/adapters/handlers.ts +++ b/src/essence/Tools/LayerManager/adapters/handlers.ts @@ -1,6 +1,7 @@ import { mmgisRequest, mmgisEmit, + mmgisShowPlugin, mmgisGetLayerCogCapabilities, mmgisGetLayerBounds, mmgisFitBounds, @@ -65,6 +66,33 @@ export const zoomToLayer = async (layerId: string): Promise => { }) } +/** + * Hands a layer to the Comparison plugin as the first of the two sides it + * swipes between. A mission without that plugin has nobody listening. + */ +export const compareLayer = (layerId: string): void => { + mmgisEmit('plugin:comparison:startWithLayer', { layerId }) +} + +export const ADD_LAYER_PLUGIN_ID = 'AddTempLayerTool' + +/** + * Reveals the "add layer from URL" form, loading the tool first if the mission + * starts it unloaded. Its own `addTempLayer:show` event would not reach it + * there, being listened for only while the tool is mounted. + */ +export const showAddLayer = (): void => { + // Widened from CommandResult: without strictNullChecks a boolean + // discriminant does not narrow, so `reason` is unreachable on the union. + mmgisShowPlugin(ADD_LAYER_PLUGIN_ID) + .then((result: { ok: boolean; reason?: string }) => { + if (!result.ok) { + console.warn(`LayerManager: showAddLayer refused: ${result.reason}`) + } + }) + .catch((err) => console.warn('LayerManager: showAddLayer failed', err)) +} + export const setColormap = async (layerId: string, colormap: string, refresh: Refresh): Promise => { if (!(await canChangeColormap(layerId))) return await mmgisRequest('layers:updateConfig', { layerUUID: layerId, updates: { currentCogColormap: colormap } }) diff --git a/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx index a1c8476bc..c6ef0bf15 100644 --- a/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx +++ b/src/essence/Tools/LayerManager/lib/geo/LayerLegend/LayerLegend.tsx @@ -44,6 +44,7 @@ export type LayerLegendProps = { onRescaleChange?: (layerId: string, min: number, max: number) => void onZoomToLayer?: (layerId: string) => void canZoomToLayer?: (layerId: string) => Promise + onCompareLayer?: (layerId: string) => void } export function LayerLegend({ @@ -56,6 +57,7 @@ export function LayerLegend({ onRescaleChange, onZoomToLayer, canZoomToLayer, + onCompareLayer, }: LayerLegendProps) { const { id, @@ -162,6 +164,23 @@ export function LayerLegend({ : undefined, onSelect: () => onZoomToLayer?.(id), }, + // Inert rather than absent on a layer that is switched off: a + // comparison reads two drawn layers against each other, and the way to + // reach it should stay visible meanwhile. + ...(onCompareLayer + ? [ + { + id: 'compare-layer', + label: 'Compare layer', + icon: 'compare-layer', + disabled: !isVisible, + title: !isVisible + ? 'Turn this layer on to compare it' + : undefined, + onSelect: () => onCompareLayer(id), + } satisfies PopoverMenuItem, + ] + : []), ] const handleVisibilityToggle = () => { diff --git a/src/essence/Tools/LayerManager/lib/geo/LayerLegendList/LayerLegendList.tsx b/src/essence/Tools/LayerManager/lib/geo/LayerLegendList/LayerLegendList.tsx index 9a110cba3..e94559881 100644 --- a/src/essence/Tools/LayerManager/lib/geo/LayerLegendList/LayerLegendList.tsx +++ b/src/essence/Tools/LayerManager/lib/geo/LayerLegendList/LayerLegendList.tsx @@ -12,6 +12,7 @@ export type LayerLegendListProps = { onRescaleChange?: LayerLegendProps['onRescaleChange'] onZoomToLayer?: LayerLegendProps['onZoomToLayer'] canZoomToLayer?: LayerLegendProps['canZoomToLayer'] + onCompareLayer?: LayerLegendProps['onCompareLayer'] } export function LayerLegendList({ @@ -24,6 +25,7 @@ export function LayerLegendList({ onRescaleChange, onZoomToLayer, canZoomToLayer, + onCompareLayer, }: LayerLegendListProps) { if (!layers || layers.length === 0) { return ( @@ -45,6 +47,7 @@ export function LayerLegendList({ onRescaleChange={onRescaleChange} onZoomToLayer={onZoomToLayer} canZoomToLayer={canZoomToLayer} + onCompareLayer={onCompareLayer} /> ))} diff --git a/src/essence/Tools/LayerManager/lib/geo/LayerManagerPanel/LayerManagerPanel.tsx b/src/essence/Tools/LayerManager/lib/geo/LayerManagerPanel/LayerManagerPanel.tsx index c930c16c7..7bb77f4a2 100644 --- a/src/essence/Tools/LayerManager/lib/geo/LayerManagerPanel/LayerManagerPanel.tsx +++ b/src/essence/Tools/LayerManager/lib/geo/LayerManagerPanel/LayerManagerPanel.tsx @@ -17,6 +17,9 @@ export type LayerManagerPanelProps = { onRescaleChange?: (layerId: string, min: number, max: number) => void onZoomToLayer?: LayerLegendListProps['onZoomToLayer'] canZoomToLayer?: LayerLegendListProps['canZoomToLayer'] + onCompareLayer?: LayerLegendListProps['onCompareLayer'] + /** Opens the host's "add layer" surface. No handler, no button. */ + onAddLayer?: () => void } export function LayerManagerPanel({ @@ -30,9 +33,26 @@ export function LayerManagerPanel({ onRescaleChange, onZoomToLayer, canZoomToLayer, + onCompareLayer, + onAddLayer, }: LayerManagerPanelProps) { return (
+ {onAddLayer && ( +
+ +
+ )}
{loading ? (
@@ -49,6 +69,7 @@ export function LayerManagerPanel({ onRescaleChange={onRescaleChange} onZoomToLayer={onZoomToLayer} canZoomToLayer={canZoomToLayer} + onCompareLayer={onCompareLayer} /> )}
diff --git a/src/essence/Tools/LayerManager/lib/geo/icons/add.svg b/src/essence/Tools/LayerManager/lib/geo/icons/add.svg new file mode 100644 index 000000000..f494656c6 --- /dev/null +++ b/src/essence/Tools/LayerManager/lib/geo/icons/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/essence/Tools/LayerManager/lib/geo/icons/compare-layer.svg b/src/essence/Tools/LayerManager/lib/geo/icons/compare-layer.svg new file mode 100644 index 000000000..d10fb1254 --- /dev/null +++ b/src/essence/Tools/LayerManager/lib/geo/icons/compare-layer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-manager-panel.scss b/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-manager-panel.scss index 965b5d10d..3768e3592 100644 --- a/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-manager-panel.scss +++ b/src/essence/Tools/LayerManager/lib/styles/components-geo/layer-manager-panel.scss @@ -8,8 +8,58 @@ color: var(--theme-color-ink, #1b1b1b); font-family: 'Lato', sans-serif; + /* "Add layer from URL" trigger, pinned above the scrolling legend list */ + &__header { + flex: 0 0 auto; + padding: var(--theme-spacing-1, 0.5rem); + } + + &__add-layer { + display: flex; + align-items: center; + justify-content: center; + gap: var(--theme-spacing-05, 0.25rem); + width: 100%; + padding: var(--theme-spacing-1, 0.5rem); + background: transparent; + border: 1px solid var(--theme-color-primary-lighter, #c4dbfa); + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-primary, #137480); + font-family: inherit; + font-size: var(--theme-font-size-2xs, 14px); + font-weight: var(--theme-font-weight-normal, 400); + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: var(--theme-color-primary-lightest, #eaf0fd); + } + + &:focus-visible { + outline: 2px solid var(--theme-color-primary, #137480); + outline-offset: 1px; + } + } + + &__add-layer-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + background-color: currentColor; + -webkit-mask-image: url('../../geo/icons/add.svg'); + mask-image: url('../../geo/icons/add.svg'); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + } + &__content { flex: 1; + /* Scrolls under the header rather than pushing the panel taller */ + min-height: 0; /* Inset so hovered legend cards don't butt against the panel edges */ padding: var(--theme-spacing-1, 0.5rem); overflow-y: auto; diff --git a/src/essence/Tools/LayerManager/lib/styles/components-geo/popover-menu.scss b/src/essence/Tools/LayerManager/lib/styles/components-geo/popover-menu.scss index 79e75950f..6242155c8 100644 --- a/src/essence/Tools/LayerManager/lib/styles/components-geo/popover-menu.scss +++ b/src/essence/Tools/LayerManager/lib/styles/components-geo/popover-menu.scss @@ -85,5 +85,10 @@ -webkit-mask-image: url('../../geo/icons/zoom-to-layer.svg'); mask-image: url('../../geo/icons/zoom-to-layer.svg'); } + + &--compare-layer { + -webkit-mask-image: url('../../geo/icons/compare-layer.svg'); + mask-image: url('../../geo/icons/compare-layer.svg'); + } } }