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:
committed by
GitHub
co-authored by
dashie
Isabel Matwawana
parent
8556c2e332
commit
d6b0b1ab62
@@ -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) {
|
||||
|
||||
@@ -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
@@ -26,6 +26,8 @@ export const defaultOptions: Partial<Options> = {
|
||||
|
||||
export interface MapViewConfig {
|
||||
allLayers?: boolean;
|
||||
dashboardVariable?: boolean;
|
||||
dashboardVariableName?: string;
|
||||
id: string;
|
||||
lastOnly?: boolean;
|
||||
lat?: number;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user