diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md index 970e38f8e5..2fe285b9f5 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md @@ -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. diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/types.gen.ts index 1e8da99dea..cb10c7fbcb 100644 --- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/types.gen.ts @@ -28,6 +28,8 @@ export const defaultOptions: Partial = { export interface MapViewConfig { allLayers?: boolean; + dashboardVariable?: boolean; + dashboardVariableName?: string; id: string; lastOnly?: boolean; lat?: number; diff --git a/public/app/plugins/panel/geomap/GeomapPanel.test.tsx b/public/app/plugins/panel/geomap/GeomapPanel.test.tsx new file mode 100644 index 0000000000..9e6da1cdc9 --- /dev/null +++ b/public/app/plugins/panel/geomap/GeomapPanel.test.tsx @@ -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, + 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) => ({ + ...baseProps, + options: { + ...baseProps.options, + view: { + ...baseProps.options.view, + dashboardVariable: false, + }, + }, +}); + +describe('GeomapPanel - View Listener', () => { + let panel: GeomapPanel; + let mockView: Partial>; + let mockMap: Partial>; + let props: ComponentProps; + 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(); + }); + }); +}); diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index cb532f0ed9..02d392795f 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -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 { layers: MapLayerState[] = []; readonly byName = new Map(); + mapViewData?: string; + constructor(props: Props) { super(props); this.state = { ttipOpen: false, legends: [] }; @@ -110,6 +113,20 @@ export class GeomapPanel extends Component { 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 { // 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 { // 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 { 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 { } 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 { 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 = () => { diff --git a/public/app/plugins/panel/geomap/editor/VariableNameEditor.tsx b/public/app/plugins/panel/geomap/editor/VariableNameEditor.tsx new file mode 100644 index 0000000000..236eeaa092 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/VariableNameEditor.tsx @@ -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) => { + 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 ( + + ); +}; diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index a88a3bfbe0..abbeae1a03 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -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(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) { diff --git a/public/app/plugins/panel/geomap/panelcfg.cue b/public/app/plugins/panel/geomap/panelcfg.cue index 7384ec581e..25e409f5bd 100644 --- a/public/app/plugins/panel/geomap/panelcfg.cue +++ b/public/app/plugins/panel/geomap/panelcfg.cue @@ -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: { diff --git a/public/app/plugins/panel/geomap/panelcfg.gen.ts b/public/app/plugins/panel/geomap/panelcfg.gen.ts index c7177a9a74..4694d45632 100644 --- a/public/app/plugins/panel/geomap/panelcfg.gen.ts +++ b/public/app/plugins/panel/geomap/panelcfg.gen.ts @@ -26,6 +26,8 @@ export const defaultOptions: Partial = { export interface MapViewConfig { allLayers?: boolean; + dashboardVariable?: boolean; + dashboardVariableName?: string; id: string; lastOnly?: boolean; lat?: number; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e785070333..5e08109719 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {