Geomap: Update dashboard variable with view data (#116208)

* Geomap: update dashboard variable with view data

* Update docs

* Update geomap panel

* Update spacing in cue file

* Make variable name an option

* Add test coverage

* Update docs to be consistent with implementation

* Fix linter and i18n

* Update public/app/plugins/panel/geomap/module.tsx

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update public/app/plugins/panel/geomap/module.tsx

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update public/app/plugins/panel/geomap/module.tsx

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Clean up docs

* Fix typo

* Add translations

* Fix bug with variable editor

* Remove console logs

* Remove debug tests

* Ensure view change registers listener

---------

Co-authored-by: dashie <dashie.dev@gmail.com>
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Drew Slobodnjak
2026-02-20 16:30:34 -08:00
committed by GitHub
co-authored by dashie Isabel Matwawana
parent 8556c2e332
commit d6b0b1ab62
9 changed files with 1072 additions and 14 deletions
@@ -189,6 +189,20 @@ You might need to reload the dashboard for this feature to work.
The **No map repeating** option prevents the base map tiles from repeating horizontally when you pan across the world. This constrains the view to a single instance of the world map and avoids visual confusion when displaying global datasets. Enabling this option requires the map to reinitialize.
#### Sync view to dashboard variable
Stores the current map's view extents in a dashboard variable of your choosing.
This is particularly useful for dynamically querying data based on the map's current extents.
To use the option, follow these steps:
1. Create a **Custom** type dashboard variable.
2. Return to panel configuration and toggle on this switch.
3. Select the newly created variable from the **Variable name** drop-down list.
4. Use the variable in your queries.
The variable contains comma-separated coordinates (EPSG:4326): `minLon,minLat,maxLon,maxLat`
### Map layers options
Geomaps support showing multiple layers. Each layer determines how you visualize geospatial data on top of the base map.
@@ -28,6 +28,8 @@ export const defaultOptions: Partial<Options> = {
export interface MapViewConfig {
allLayers?: boolean;
dashboardVariable?: boolean;
dashboardVariableName?: string;
id: string;
lastOnly?: boolean;
lat?: number;
@@ -0,0 +1,903 @@
import OpenLayersMap from 'ol/Map';
import View from 'ol/View';
import { transformExtent } from 'ol/proj';
import { ComponentProps } from 'react';
import { dateTime, EventBusSrv, LoadingState } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { GeomapPanel } from './GeomapPanel';
import { TooltipMode } from './types';
// Mock React components
jest.mock('./GeomapTooltip', () => ({
GeomapTooltip: () => null,
}));
jest.mock('./GeomapOverlay', () => ({
GeomapOverlay: () => null,
}));
jest.mock('./components/DebugOverlay', () => ({
DebugOverlay: () => null,
}));
jest.mock('./components/MeasureOverlay', () => ({
MeasureOverlay: () => null,
}));
jest.mock('./components/MeasureVectorLayer', () => ({
MeasureVectorLayer: jest.fn(),
}));
jest.mock('./layers/data/markersLayer', () => ({
defaultMarkersConfig: {
type: 'markers',
name: 'Markers',
},
}));
jest.mock('./layers/registry', () => ({
DEFAULT_BASEMAP_CONFIG: {
type: 'default',
name: 'Default',
},
}));
jest.mock('./utils/actions', () => ({
getActions: jest.fn().mockReturnValue({}),
}));
jest.mock('./utils/getLayersExtent', () => ({
getLayersExtent: jest.fn().mockReturnValue([0, 0, 100, 100]),
}));
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
config: {
theme2: {},
},
locationService: {
partial: jest.fn(),
},
getTemplateSrv: jest.fn().mockReturnValue({
containsTemplate: jest.fn().mockReturnValue(false),
getVariables: jest.fn().mockReturnValue([]),
}),
}));
jest.mock('app/core/app_events', () => ({
appEvents: {
subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
},
}));
jest.mock('./utils/utils', () => ({
updateMap: jest.fn(),
getNewOpenLayersMap: jest.fn(),
notifyPanelEditor: jest.fn(),
hasVariableDependencies: jest.fn().mockReturnValue(false),
hasLayerData: jest.fn().mockReturnValue(false),
}));
jest.mock('./utils/tooltip', () => ({
setTooltipListeners: jest.fn(),
pointerClickListener: jest.fn(),
pointerMoveListener: jest.fn(),
}));
jest.mock('./utils/layers', () => ({
initLayer: jest.fn().mockResolvedValue({
layer: {},
handler: {},
options: {},
}),
applyLayerFilter: jest.fn(),
}));
jest.mock('./view', () => ({
centerPointRegistry: {
getIfExists: jest.fn().mockReturnValue(null),
},
MapCenterID: {
Fit: 'fit',
Coordinates: 'coords',
},
}));
jest.mock('ol/proj', () => ({
fromLonLat: jest.fn((coord) => coord),
transformExtent: jest.fn((extent) => extent),
}));
jest.mock('./globalStyles', () => ({
getGlobalStyles: jest.fn().mockReturnValue({}),
}));
jest.mock('ol/interaction/MouseWheelZoom', () => {
return jest.fn().mockImplementation(() => ({
setActive: jest.fn(),
}));
});
jest.mock('ol/View', () => {
return jest.fn().mockImplementation(() => ({
on: jest.fn(),
un: jest.fn(),
calculateExtent: jest.fn(),
getResolutionForExtent: jest.fn(),
fit: jest.fn(),
setResolution: jest.fn(),
getZoom: jest.fn(),
setZoom: jest.fn(),
setCenter: jest.fn(),
setMaxZoom: jest.fn(),
setMinZoom: jest.fn(),
}));
});
// Import after mocks are set up
// Test constants
const MOCK_EXTENT = [100, 200, 300, 400];
const MOCK_EXTENT_4326 = [10, 20, 30, 40];
const DEBOUNCE_TIMEOUT = 500;
const VARIABLE_NAME = 'map_view';
// Helper to create props with dashboard variable enabled
const createPropsWithVariable = (
baseProps: ComponentProps<typeof GeomapPanel>,
variableName: string | undefined = VARIABLE_NAME
) => ({
...baseProps,
options: {
...baseProps.options,
view: {
...baseProps.options.view,
dashboardVariable: true,
dashboardVariableName: variableName,
},
},
});
// Helper to create props without dashboard variable
const createPropsWithoutVariable = (baseProps: ComponentProps<typeof GeomapPanel>) => ({
...baseProps,
options: {
...baseProps.options,
view: {
...baseProps.options.view,
dashboardVariable: false,
},
},
});
describe('GeomapPanel - View Listener', () => {
let panel: GeomapPanel;
let mockView: Partial<jest.Mocked<View>>;
let mockMap: Partial<jest.Mocked<OpenLayersMap>>;
let props: ComponentProps<typeof GeomapPanel>;
let viewOnMock: jest.Mock;
let viewUnMock: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// Suppress expected React warnings about setState before mount
jest.spyOn(console, 'error').mockImplementation((message) => {
if (
typeof message === 'string' &&
(message.includes("Can't call setState on a component that is not yet mounted") ||
message.includes("Can't call %s on a component that is not yet mounted"))
) {
return;
}
});
// Create mock view with event listener methods
viewOnMock = jest.fn().mockReturnValue({ listener: jest.fn() });
viewUnMock = jest.fn();
mockView = {
on: viewOnMock,
un: viewUnMock,
calculateExtent: jest.fn().mockReturnValue([0, 0, 100, 100]),
getResolutionForExtent: jest.fn().mockReturnValue(1),
fit: jest.fn(),
setResolution: jest.fn(),
getZoom: jest.fn().mockReturnValue(5),
setZoom: jest.fn(),
setCenter: jest.fn(),
setMaxZoom: jest.fn(),
setMinZoom: jest.fn(),
};
// Make the View constructor return our tracked mockView so that
// views created by initMapView use the same on/un mocks we assert against
const ViewConstructor = require('ol/View');
ViewConstructor.mockImplementation(() => mockView);
mockMap = {
getView: jest.fn().mockReturnValue(mockView as unknown as View),
setView: jest.fn(),
addLayer: jest.fn(),
addInteraction: jest.fn(),
getLayers: jest.fn().mockReturnValue({
forEach: jest.fn(),
}),
getControls: jest.fn().mockReturnValue({
clear: jest.fn(),
}),
addControl: jest.fn(),
getSize: jest.fn().mockReturnValue([800, 600]),
updateSize: jest.fn(),
dispose: jest.fn(),
};
const { getNewOpenLayersMap } = require('./utils/utils');
getNewOpenLayersMap.mockReturnValue(mockMap as unknown as OpenLayersMap);
const now = dateTime();
props = {
id: 1,
data: {
state: LoadingState.Done,
series: [],
timeRange: {
from: now,
to: now,
raw: { from: 'now-1h', to: 'now' },
},
},
title: 'Test Panel',
transparent: false,
width: 800,
height: 600,
renderCounter: 0,
options: {
view: {
id: 'coords',
lat: 0,
lon: 0,
zoom: 1,
},
controls: {
showZoom: true,
showAttribution: true,
},
basemap: {
type: 'default',
name: 'Default',
},
layers: [],
tooltip: {
mode: TooltipMode.None,
},
},
onOptionsChange: jest.fn(),
onFieldConfigChange: jest.fn(),
replaceVariables: jest.fn((str) => str),
timeRange: {
from: now,
to: now,
raw: { from: 'now-1h', to: 'now' },
},
timeZone: 'browser',
fieldConfig: {
defaults: {},
overrides: [],
},
onChangeTimeRange: jest.fn(),
eventBus: new EventBusSrv(),
};
panel = new GeomapPanel(props);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('View listener registration', () => {
it('should register view listener when dashboardVariable is enabled', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
expect(viewOnMock).toHaveBeenCalledWith('change', expect.any(Function));
});
it('should not register view listener when dashboardVariable is disabled', async () => {
panel = new GeomapPanel(createPropsWithoutVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
expect(viewOnMock).not.toHaveBeenCalled();
});
});
describe('View listener cleanup', () => {
it('should unregister view listener on component unmount', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
// Verify listener was registered
expect(viewOnMock).toHaveBeenCalled();
const registeredKey = viewOnMock.mock.results[0].value;
// Unmount component
panel.componentWillUnmount();
// Verify listener was unregistered
expect(viewUnMock).toHaveBeenCalledWith('change', registeredKey.listener);
});
it('should cleanup existing listener before registering new one during re-initialization', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
// First initialization
await panel.initMapAsync(div);
expect(viewOnMock).toHaveBeenCalledTimes(1);
const firstKey = viewOnMock.mock.results[0].value;
// Second initialization (simulating re-init)
await panel.initMapAsync(div);
// Should have unregistered the first listener
expect(viewUnMock).toHaveBeenCalledWith('change', firstKey.listener);
// Should have registered a new listener
expect(viewOnMock).toHaveBeenCalledTimes(2);
});
});
describe('updateGeoVariables', () => {
it('should update dashboard variable with transformed extent', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
// Mock transformExtent to return a specific value
jest.mocked(transformExtent).mockReturnValue(MOCK_EXTENT_4326);
// Trigger the view change callback
const changeCallback = viewOnMock.mock.calls[0][1];
changeCallback();
// Fast-forward past debounce timeout
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
// Verify locationService.partial was called with correct parameters
expect(locationService.partial).toHaveBeenCalledWith({ [`var-${VARIABLE_NAME}`]: `${MOCK_EXTENT_4326}` }, true);
});
it('should debounce multiple rapid view changes', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
// Trigger multiple rapid changes
const changeCallback = viewOnMock.mock.calls[0][1];
changeCallback();
jest.advanceTimersByTime(200);
changeCallback();
jest.advanceTimersByTime(200);
changeCallback();
// At this point, locationService.partial should not have been called yet
expect(locationService.partial).not.toHaveBeenCalled();
// Fast-forward past the final debounce timeout
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
// Should have been called only once due to debouncing
expect(locationService.partial).toHaveBeenCalledTimes(1);
});
it('should not update variable if dashboardVariableName is not set', async () => {
// Create props with dashboardVariable enabled but no variable name
const propsWithoutVariableName = {
...props,
options: {
...props.options,
view: {
...props.options.view,
dashboardVariable: true,
dashboardVariableName: undefined,
},
},
};
panel = new GeomapPanel(propsWithoutVariableName);
const div = document.createElement('div');
await panel.initMapAsync(div);
// Clear any calls from initialization
jest.mocked(locationService.partial).mockClear();
// Trigger the view change callback
const changeCallback = viewOnMock.mock.calls[0][1];
changeCallback();
// Fast-forward past debounce timeout
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
// Should not have called locationService.partial
expect(locationService.partial).not.toHaveBeenCalled();
});
it('should clear pending timeout on unmount', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
// Trigger a view change
const changeCallback = viewOnMock.mock.calls[0][1];
changeCallback();
// Unmount before the debounce timeout completes
jest.advanceTimersByTime(200);
panel.componentWillUnmount();
// Fast-forward past the debounce timeout
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
// locationService.partial should not have been called
expect(locationService.partial).not.toHaveBeenCalled();
});
});
describe('Integration with view changes', () => {
it('should handle view extent calculation and transformation correctly', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
mockView.calculateExtent!.mockReturnValue(MOCK_EXTENT);
jest.mocked(transformExtent).mockReturnValue(MOCK_EXTENT_4326);
// Trigger the view change
const changeCallback = viewOnMock.mock.calls[0][1];
changeCallback();
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
// Verify the extent was calculated and transformed
expect(mockView.calculateExtent).toHaveBeenCalled();
expect(transformExtent).toHaveBeenCalledWith(MOCK_EXTENT, 'EPSG:3857', 'EPSG:4326');
expect(locationService.partial).toHaveBeenCalledWith({ [`var-${VARIABLE_NAME}`]: `${MOCK_EXTENT_4326}` }, true);
});
});
describe('Component lifecycle', () => {
it('should handle shouldComponentUpdate when map is not initialized', () => {
const result = panel.shouldComponentUpdate(props);
expect(result).toBe(true);
});
it('should update map size when dimensions change', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const newProps = {
...props,
width: 1000,
height: 800,
};
panel.shouldComponentUpdate(newProps);
expect(mockMap.updateSize).toHaveBeenCalled();
});
it('should handle data changes', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const newData = {
...props.data,
series: [{ fields: [], length: 0 }],
};
const newProps = {
...props,
data: newData,
};
panel.shouldComponentUpdate(newProps);
expect(panel.props.data).not.toBe(newData);
});
it('should handle componentDidUpdate for dimension changes', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const prevProps = { ...props, width: 600, height: 400 };
// Update the panel's props
Object.assign(panel.props, { width: 1000, height: 800 });
panel.componentDidUpdate(prevProps);
expect(mockMap.updateSize).toHaveBeenCalled();
});
});
describe('Map initialization edge cases', () => {
it('should handle null div in initMapAsync', async () => {
await panel.initMapAsync(null);
expect(mockMap.dispose).not.toHaveBeenCalled();
});
it('should dispose old map on re-initialization', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
expect(mockMap.dispose).not.toHaveBeenCalled();
await panel.initMapAsync(div);
expect(mockMap.dispose).toHaveBeenCalled();
});
it('should handle map initialization without dashboard variable', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
expect(viewOnMock).not.toHaveBeenCalled();
expect(mockMap.addLayer).toHaveBeenCalled();
expect(mockMap.addInteraction).toHaveBeenCalled();
});
});
describe('View initialization', () => {
it('should initialize view with default options', async () => {
const ViewConstructor = require('ol/View');
const div = document.createElement('div');
await panel.initMapAsync(div);
const view = panel.initMapView(props.options.view);
expect(view).toBeDefined();
expect(ViewConstructor).toHaveBeenCalled();
});
it('should initialize view with noRepeat enabled', async () => {
const ViewConstructor = require('ol/View');
const div = document.createElement('div');
await panel.initMapAsync(div);
const viewConfig = {
...props.options.view,
noRepeat: true,
};
const view = panel.initMapView(viewConfig);
expect(view).toBeDefined();
expect(ViewConstructor).toHaveBeenCalled();
});
it('should handle shared view configuration', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const viewConfig = {
...props.options.view,
shared: true,
};
const view1 = panel.initMapView(viewConfig);
const view2 = panel.initMapView(viewConfig);
// Both should reference the same shared view
expect(view1).toBeDefined();
expect(view2).toBeDefined();
expect(view1).toBe(view2);
});
});
describe('Tooltip methods', () => {
it('should clear tooltip when not open', () => {
panel.state = { ttip: { point: {}, pageX: 100, pageY: 100 }, ttipOpen: false, legends: [] };
const setStateSpy = jest.spyOn(panel, 'setState');
panel.clearTooltip();
expect(setStateSpy).toHaveBeenCalledWith({ ttipOpen: false, ttip: undefined });
});
it('should not clear tooltip when open', () => {
const ttip = { point: {}, pageX: 100, pageY: 100 };
panel.state = { ttip, ttipOpen: true, legends: [] };
const setStateSpy = jest.spyOn(panel, 'setState');
panel.clearTooltip();
expect(setStateSpy).not.toHaveBeenCalled();
});
it('should close tooltip popup', () => {
const setStateSpy = jest.spyOn(panel, 'setState');
panel.tooltipPopupClosed();
expect(setStateSpy).toHaveBeenCalledWith({ ttipOpen: false, ttip: undefined });
});
});
describe('Options changes', () => {
it('should handle noRepeat option change', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const oldOptions = props.options;
const newOptions = {
...props.options,
view: {
...props.options.view,
noRepeat: true,
},
};
const initMapAsyncSpy = jest.spyOn(panel, 'initMapAsync');
panel.optionsChanged(oldOptions, newOptions);
expect(initMapAsyncSpy).toHaveBeenCalled();
});
it('should handle view changes without noRepeat', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const ViewConstructor = require('ol/View');
ViewConstructor.mockClear();
const oldOptions = props.options;
const newOptions = {
...props.options,
view: {
...props.options.view,
zoom: 10,
},
};
panel.optionsChanged(oldOptions, newOptions);
expect(ViewConstructor).toHaveBeenCalled();
expect(mockMap.setView).toHaveBeenCalled();
});
it('should handle controls changes', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const oldOptions = props.options;
const newOptions = {
...props.options,
controls: {
showZoom: false,
showAttribution: false,
},
};
panel.optionsChanged(oldOptions, newOptions);
expect(mockMap.getControls).toHaveBeenCalled();
});
it('should register view listener when dashboardVariable is enabled via options change', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
// Initially no listener registered
expect(viewOnMock).not.toHaveBeenCalled();
const oldOptions = props.options;
const newOptions = createPropsWithVariable(props).options;
panel.optionsChanged(oldOptions, newOptions);
// Listener should now be registered on the new view
expect(viewOnMock).toHaveBeenCalledWith('change', expect.any(Function));
});
it('should immediately update the variable when dashboardVariable is first enabled', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
jest.mocked(transformExtent).mockReturnValue(MOCK_EXTENT_4326);
const oldOptions = props.options;
const newOptions = createPropsWithVariable(props).options;
panel.optionsChanged(oldOptions, newOptions);
// Should call updateGeoVariables immediately (with debounce)
jest.advanceTimersByTime(DEBOUNCE_TIMEOUT);
expect(locationService.partial).toHaveBeenCalledWith({ [`var-${VARIABLE_NAME}`]: `${MOCK_EXTENT_4326}` }, true);
});
it('should unregister old listener and register new one when view options change with dashboardVariable enabled', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
// Listener registered during initMapAsync
expect(viewOnMock).toHaveBeenCalledTimes(1);
const firstKey = viewOnMock.mock.results[0].value;
const oldOptions = createPropsWithVariable(props).options;
const newOptions = {
...oldOptions,
view: {
...oldOptions.view,
zoom: 10,
},
};
panel.optionsChanged(oldOptions, newOptions);
// Old listener should be unregistered
expect(viewUnMock).toHaveBeenCalledWith('change', firstKey.listener);
// New listener should be registered
expect(viewOnMock).toHaveBeenCalledTimes(2);
});
it('should unregister listener when dashboardVariable is disabled via options change', async () => {
panel = new GeomapPanel(createPropsWithVariable(props));
const div = document.createElement('div');
await panel.initMapAsync(div);
expect(viewOnMock).toHaveBeenCalledTimes(1);
const registeredKey = viewOnMock.mock.results[0].value;
const oldOptions = createPropsWithVariable(props).options;
const newOptions = createPropsWithoutVariable(props).options;
panel.optionsChanged(oldOptions, newOptions);
// Old listener should be unregistered
expect(viewUnMock).toHaveBeenCalledWith('change', registeredKey.listener);
// No new listener should be registered
expect(viewOnMock).toHaveBeenCalledTimes(1);
});
});
describe('Data handling', () => {
it('should handle data changes when panel data matches', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const newData = {
...props.data,
series: [{ fields: [], length: 0 }],
};
// Set the panel's props.data to match the data we're passing
Object.assign(panel.props, { data: newData });
panel.dataChanged(newData);
// Verify applyLayerFilter is called via mock
const { applyLayerFilter } = require('./utils/layers');
expect(applyLayerFilter).toHaveBeenCalled();
});
});
describe('Legend generation', () => {
it('should generate legends for layers', () => {
const legends = panel.getLegends();
expect(Array.isArray(legends)).toBe(true);
});
it('should include legends from handlers when data exists', async () => {
const { hasLayerData } = require('./utils/utils');
hasLayerData.mockReturnValue(true);
const div = document.createElement('div');
await panel.initMapAsync(div);
// Add a layer with a legend
panel.layers = [
{
layer: {},
handler: {
init: jest.fn(),
legend: 'Test Legend',
update: jest.fn(),
},
options: { name: 'Test Layer', type: 'test' },
onChange: jest.fn(),
mouseEvents: { next: jest.fn(), subscribe: jest.fn() },
getName: () => 'Test Layer',
},
] as unknown as typeof panel.layers;
const legends = panel.getLegends();
expect(legends.length).toBeGreaterThan(0);
});
});
describe('Controls initialization', () => {
it('should initialize controls with zoom enabled', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
panel.initControls({
showZoom: true,
showAttribution: true,
});
expect(mockMap.getControls).toHaveBeenCalled();
expect(mockMap.addControl).toHaveBeenCalled();
});
it('should initialize controls with scale', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
panel.initControls({
showZoom: false,
showAttribution: false,
showScale: true,
scaleUnits: 'metric',
});
expect(mockMap.addControl).toHaveBeenCalled();
});
it('should toggle mouse wheel zoom', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
panel.initControls({
showZoom: false,
showAttribution: false,
mouseWheelZoom: true,
});
expect(panel.mouseWheelZoom?.setActive).toHaveBeenCalledWith(true);
});
it('should initialize measure overlay when enabled', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const setStateSpy = jest.spyOn(panel, 'setState');
panel.initControls({
showZoom: false,
showAttribution: false,
showMeasure: true,
});
expect(setStateSpy).toHaveBeenCalled();
});
it('should initialize debug overlay when enabled', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const setStateSpy = jest.spyOn(panel, 'setState');
panel.initControls({
showZoom: false,
showAttribution: false,
showDebug: true,
});
expect(setStateSpy).toHaveBeenCalled();
});
it('should handle controls when map is not initialized', () => {
panel.initControls({
showZoom: true,
showAttribution: true,
});
// Should not throw error
expect(mockMap.getControls).not.toHaveBeenCalled();
});
});
describe('doOptionsUpdate', () => {
it('should update options and notify editor', async () => {
const div = document.createElement('div');
await panel.initMapAsync(div);
const onOptionsChangeSpy = jest.fn();
// Use Object.defineProperty to override readonly property
Object.defineProperty(panel.props, 'onOptionsChange', {
value: onOptionsChangeSpy,
writable: true,
});
panel.doOptionsUpdate(0);
expect(onOptionsChangeSpy).toHaveBeenCalled();
});
});
});
@@ -7,6 +7,7 @@ import Attribution from 'ol/control/Attribution';
import ScaleLine from 'ol/control/ScaleLine';
import Zoom from 'ol/control/Zoom';
import { Coordinate } from 'ol/coordinate';
import { EventsKey } from 'ol/events';
import { isEmpty } from 'ol/extent';
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
import { fromLonLat, transformExtent } from 'ol/proj';
@@ -16,7 +17,7 @@ import { Subscription } from 'rxjs';
import { DataHoverEvent, PanelData, PanelProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { PanelContext, PanelContextRoot } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { VariablesChanged } from 'app/features/variables/types';
@@ -73,6 +74,8 @@ export class GeomapPanel extends Component<Props, State> {
layers: MapLayerState[] = [];
readonly byName = new Map<string, MapLayerState>();
mapViewData?: string;
constructor(props: Props) {
super(props);
this.state = { ttipOpen: false, legends: [] };
@@ -110,6 +113,20 @@ export class GeomapPanel extends Component<Props, State> {
componentWillUnmount() {
this.subs.unsubscribe();
// Clear any pending debounce timeout
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
// Unregister view listener
if (this.viewListenerKey && this.map) {
const view = this.map.getView();
view.un('change', this.viewListenerKey.listener);
this.viewListenerKey = null;
}
for (const lyr of this.layers) {
lyr.handler.dispose?.();
}
@@ -125,6 +142,12 @@ export class GeomapPanel extends Component<Props, State> {
// Check for resize
if (this.props.height !== nextProps.height || this.props.width !== nextProps.width) {
this.map.updateSize();
// update dashboard variable if enabled
const options = this.props.options;
if (options.view.dashboardVariable) {
const view = this.map.getView();
this.updateGeoVariables(view, options);
}
}
// External data changed
@@ -190,9 +213,24 @@ export class GeomapPanel extends Component<Props, State> {
// Handle incremental view changes
if (oldOptions.view !== newOptions.view) {
// Unregister existing listener from the current view before replacing it
if (this.viewListenerKey != null && this.map) {
const oldView = this.map.getView();
oldView.un('change', this.viewListenerKey.listener);
this.viewListenerKey = null;
}
const view = this.initMapView(newOptions.view);
if (this.map && view) {
this.map.setView(view);
// Register new listener if dashboard variable sync is enabled
if (newOptions.view.dashboardVariable) {
this.viewListenerKey = view.on('change', () => {
this.updateGeoVariables(view, newOptions);
});
this.updateGeoVariables(view, newOptions);
}
}
}
@@ -227,6 +265,30 @@ export class GeomapPanel extends Component<Props, State> {
this.setState({ legends: this.getLegends() });
}
// view listerner handler, used to unregister when view changes
private viewListenerKey: EventsKey | null = null;
// updateGeoVariables debounce timeout
private timeoutId: NodeJS.Timeout | null = null; // for debounce
// Updates the dashboard variable with the view extent value.
// Use a debounce strategy to wait for the user to stop dragging or zooming the map.
updateGeoVariables = (view: View, options: Options) => {
const bounds = view.calculateExtent();
const bounds4326 = transformExtent(bounds, 'EPSG:3857', 'EPSG:4326');
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
const variableName = options.view.dashboardVariableName;
if (!variableName) {
return;
}
// Store as comma-separated values: minLon,minLat,maxLon,maxLat
locationService.partial({ [`var-${variableName}`]: `${bounds4326}` }, true);
}, 500);
};
initMapAsync = async (div: HTMLDivElement | null) => {
if (!div) {
// Do not initialize new map or dispose old map
@@ -268,7 +330,9 @@ export class GeomapPanel extends Component<Props, State> {
}
this.layers = layers;
this.map = map; // redundant
this.initViewExtent(map.getView(), options.view);
const view = map.getView();
const viewConfig = options.view;
this.initViewExtent(view, viewConfig);
this.mouseWheelZoom = new MouseWheelZoom();
this.map?.addInteraction(this.mouseWheelZoom);
@@ -278,6 +342,17 @@ export class GeomapPanel extends Component<Props, State> {
notifyPanelEditor(this, layers, layers.length - 1);
this.setState({ legends: this.getLegends() });
// register view listener to update dashboard variable if enabled
if (viewConfig.dashboardVariable) {
if (this.viewListenerKey != null) {
view.un('change', this.viewListenerKey.listener);
}
this.viewListenerKey = view.on('change', () => {
this.updateGeoVariables(view, options);
});
this.updateGeoVariables(view, options);
}
};
clearTooltip = () => {
@@ -0,0 +1,31 @@
import { StandardEditorProps, VariableOrigin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getTemplateSrv } from '@grafana/runtime';
import { SuggestionsInput } from 'app/features/transformers/suggestionsInput/SuggestionsInput';
import { getVariableName } from 'app/features/variables/inspect/utils';
interface Settings {}
export const VariableNameEditor = ({ value, onChange, context }: StandardEditorProps<string, Settings>) => {
const suggestions = getTemplateSrv()
.getVariables()
.map((v) => ({
value: v.name,
label: v.name,
origin: VariableOrigin.Template,
}));
const handleChange = (newValue: string) => {
const cleanValue = getVariableName(newValue) ?? newValue;
onChange(cleanValue);
};
return (
<SuggestionsInput
value={value}
onChange={handleChange}
suggestions={suggestions}
placeholder={t('geomap.variable-name-editor.placeholder', 'Select variable')}
/>
);
};
@@ -6,6 +6,7 @@ import { commonOptionsBuilder } from '@grafana/ui';
import { GeomapPanel } from './GeomapPanel';
import { LayersEditor } from './editor/LayersEditor';
import { MapViewEditor } from './editor/MapViewEditor';
import { VariableNameEditor } from './editor/VariableNameEditor';
import { getLayerEditor } from './editor/layerEditor';
import { mapPanelChangedHandler, mapMigrationHandler } from './migrations';
import { geomapSuggestionsSupplier } from './suggestions';
@@ -51,6 +52,27 @@ export const plugin = new PanelPlugin<Options>(GeomapPanel)
defaultValue: false,
});
builder.addBooleanSwitch({
category,
path: 'view.dashboardVariable',
name: t('geomap.name-sync-view-variable', 'Sync view to dashboard variable'),
description: t(
'geomap.description-sync-view-variable',
'Store view bounds in a dashboard variable for use in queries'
),
defaultValue: false,
});
builder.addCustomEditor({
category,
id: 'view.dashboardVariableName',
path: 'view.dashboardVariableName',
name: t('geomap.name-variable-name', 'Variable name'),
description: t('geomap.description-variable-name', 'Specify the dashboard variable to store view bounds'),
editor: VariableNameEditor,
showIf: (config) => config.view?.dashboardVariable === true,
});
// eslint-disable-next-line
const state = context.instanceState as GeomapInstanceState;
if (!state?.layers) {
+14 -12
View File
@@ -34,18 +34,20 @@ composableKinds: PanelCfg: {
} @cuetsy(kind="interface")
MapViewConfig: {
id: string | *"zero"
lat?: int64 | *0
lon?: int64 | *0
zoom?: int64 | *1
minZoom?: int64
maxZoom?: int64
padding?: int64
allLayers?: bool | *true
lastOnly?: bool
layer?: string
shared?: bool
noRepeat?: bool | *false
id: string | *"zero"
lat?: int64 | *0
lon?: int64 | *0
zoom?: int64 | *1
minZoom?: int64
maxZoom?: int64
padding?: int64
allLayers?: bool | *true
lastOnly?: bool
layer?: string
shared?: bool
noRepeat?: bool | *false
dashboardVariable?: bool
dashboardVariableName?: string
} @cuetsy(kind="interface")
ControlsOptions: {
+2
View File
@@ -26,6 +26,8 @@ export const defaultOptions: Partial<Options> = {
export interface MapViewConfig {
allLayers?: boolean;
dashboardVariable?: boolean;
dashboardVariableName?: string;
id: string;
lastOnly?: boolean;
lat?: number;
+7
View File
@@ -8657,6 +8657,8 @@
"description-show-measure": "Show tools for making measurements on the map",
"description-show-scale": "Indicate map scale",
"description-show-zoom": "Show zoom control buttons in the upper left corner",
"description-sync-view-variable": "Store view bounds in a dashboard variable for use in queries",
"description-variable-name": "Specify the dashboard variable to store view bounds",
"fit-map-view-editor": {
"all-layers-editor-fragment": {
"label-layer": "Layer"
@@ -8718,7 +8720,9 @@
"name-show-measure": "Show measure tools",
"name-show-scale": "Show scale",
"name-show-zoom": "Show zoom control",
"name-sync-view-variable": "Sync view to dashboard variable",
"name-tooltip": "Tooltip",
"name-variable-name": "Variable name",
"photos-layer": {
"noFieldsMessage-no-string-fields": "No string fields found"
},
@@ -8771,6 +8775,9 @@
},
"utils": {
"get-next-layer-name": "Layer {{name}}"
},
"variable-name-editor": {
"placeholder": "Select variable"
}
},
"get-enterprise": {