Geomap: Add option to toggle no-repeating (#108201)

* Geomap: Add option to toggle no-repeating

* Add option and apply to all basemaps

* Clean up some comments

* Update docs

* Add tests

* Fix option change handling issues

* Update translations

* Fix e2e test
This commit is contained in:
Drew Slobodnjak 2025-07-28 14:06:33 -07:00 committed by GitHub
parent 4b9e03e7c0
commit bee169d7a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 380 additions and 19 deletions

View File

@ -184,6 +184,14 @@ The **Share view** option allows you to link the movement and zoom actions of mu
You might need to reload the dashboard for this feature to work.
{{< /admonition >}}
#### No map repeating
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.
{{< admonition type="note" >}}
Enabling this option requires the map to reinitialize.
{{< /admonition >}}
### Map layers options
Geomaps support showing multiple layers. Each layer determines how you visualize geospatial data on top of the base map.

View File

@ -13,7 +13,7 @@ describe('Geomap layer types', () => {
it('Tests changing the layer type', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
cy.get('[data-testid="layer-drag-drop-list"]').should('be.visible');
cy.get('[data-testid="layer-drag-drop-list"]').scrollIntoView().should('be.visible');
e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).should('be.visible');
cy.get('[data-testid="layer-drag-drop-list"]').contains('markers');

View File

@ -33,6 +33,7 @@ export interface MapViewConfig {
lon?: number;
maxZoom?: number;
minZoom?: number;
noRepeat?: boolean;
padding?: number;
shared?: boolean;
zoom?: number;
@ -43,6 +44,7 @@ export const defaultMapViewConfig: Partial<MapViewConfig> = {
id: 'zero',
lat: 0,
lon: 0,
noRepeat: false,
zoom: 1,
};

View File

@ -6,6 +6,8 @@ export interface MapLayerOptions<TConfig = any> extends raw.MapLayerOptions {
// Custom options depending on the type
config?: TConfig;
filterData?: MatcherConfig;
// Disable world repetition for basemap layers
noRepeat?: boolean;
}
export interface DataQuery extends raw.DataQuery {

View File

@ -2,14 +2,14 @@ import { css } from '@emotion/css';
import { Global } from '@emotion/react';
import OpenLayersMap from 'ol/Map';
import MapBrowserEvent from 'ol/MapBrowserEvent';
import View from 'ol/View';
import View, { ViewOptions } from 'ol/View';
import Attribution from 'ol/control/Attribution';
import ScaleLine from 'ol/control/ScaleLine';
import Zoom from 'ol/control/Zoom';
import { Coordinate } from 'ol/coordinate';
import { isEmpty } from 'ol/extent';
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
import { fromLonLat } from 'ol/proj';
import { fromLonLat, transformExtent } from 'ol/proj';
import { Component, ReactNode } from 'react';
import * as React from 'react';
import { Subscription } from 'rxjs';
@ -132,11 +132,6 @@ export class GeomapPanel extends Component<Props, State> {
this.dataChanged(nextProps.data);
}
// Options changed
if (this.props.options !== nextProps.options) {
this.optionsChanged(nextProps.options);
}
return true; // always?
}
@ -148,6 +143,10 @@ export class GeomapPanel extends Component<Props, State> {
if (this.map && this.props.data !== prevProps.data) {
this.dataChanged(this.props.data);
}
// Handle options changes
if (this.props.options !== prevProps.options) {
this.optionsChanged(prevProps.options, this.props.options);
}
}
/** This function will actually update the JSON model */
@ -177,18 +176,29 @@ export class GeomapPanel extends Component<Props, State> {
*
* NOTE: changes to basemap and layers are handled independently
*/
optionsChanged(options: Options) {
const oldOptions = this.props.options;
if (options.view !== oldOptions.view) {
const view = this.initMapView(options.view);
optionsChanged(oldOptions: Options, newOptions: Options) {
// First check if noRepeat changed - requires full map reinitialization
const noRepeatChanged = oldOptions.view?.noRepeat !== newOptions.view?.noRepeat;
if (noRepeatChanged) {
if (this.mapDiv) {
this.initMapRef(this.mapDiv);
}
// Skip other options processing
return;
}
// Handle incremental view changes
if (oldOptions.view !== newOptions.view) {
const view = this.initMapView(newOptions.view);
if (this.map && view) {
this.map.setView(view);
}
}
if (options.controls !== oldOptions.controls) {
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
// Handle controls changes
if (newOptions.controls !== oldOptions.controls) {
this.initControls(newOptions.controls ?? { showZoom: true, showAttribution: true });
}
}
@ -234,7 +244,12 @@ export class GeomapPanel extends Component<Props, State> {
this.byName.clear();
const layers: MapLayerState[] = [];
try {
layers.push(await initLayer(this, map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true));
// Pass noRepeat setting to basemap layer
const basemapOptions = {
...(options.basemap ?? DEFAULT_BASEMAP_CONFIG),
noRepeat: options.view?.noRepeat ?? false,
};
layers.push(await initLayer(this, map, basemapOptions, true));
// Default layer values
if (!options.layers) {
@ -284,11 +299,24 @@ export class GeomapPanel extends Component<Props, State> {
};
initMapView = (config: MapViewConfig): View | undefined => {
let view = new View({
const noRepeat = config.noRepeat ?? false;
let viewOptions: ViewOptions = {
center: [0, 0],
zoom: 1,
showFullExtent: true, // allows zooming so the full range is visible
});
};
// Only apply constraints when no-repeat is enabled
if (noRepeat) {
// Define the world extent in EPSG:3857 (Web Mercator)
const worldExtent = [-180, -85.05112878, 180, 85.05112878]; // [minx, miny, maxx, maxy] in EPSG:4326
const projectedExtent = transformExtent(worldExtent, 'EPSG:4326', 'EPSG:3857');
viewOptions.extent = projectedExtent;
viewOptions.showFullExtent = false;
viewOptions.constrainOnlyCenter = false;
}
let view = new View(viewOptions);
// With shared views, all panels use the same view instance
if (config.shared) {

View File

@ -0,0 +1,103 @@
import OpenLayersMap from 'ol/Map';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import { EventBus, GrafanaTheme2, MapLayerOptions } from '@grafana/data';
import { carto, CartoConfig, LayerTheme } from './carto';
describe('CARTO basemap layer noRepeat functionality', () => {
let mockMap: OpenLayersMap;
let mockEventBus: EventBus;
let mockTheme: GrafanaTheme2;
beforeEach(() => {
mockMap = {} as OpenLayersMap;
mockEventBus = {} as EventBus;
mockTheme = { isDark: false } as GrafanaTheme2;
});
it('should set wrapX to false when noRepeat is true', async () => {
const options: MapLayerOptions<CartoConfig> = {
name: 'Test CARTO Layer',
type: 'carto',
config: {
theme: LayerTheme.Light,
showLabels: true,
},
noRepeat: true,
};
const result = await carto.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(false);
});
it('should set wrapX to true when noRepeat is false', async () => {
const options: MapLayerOptions<CartoConfig> = {
name: 'Test CARTO Layer',
type: 'carto',
config: {
theme: LayerTheme.Dark,
showLabels: false,
},
noRepeat: false,
};
const result = await carto.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(true);
});
it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => {
const options: MapLayerOptions<CartoConfig> = {
name: 'Test CARTO Layer',
type: 'carto',
config: {
theme: LayerTheme.Auto,
showLabels: true,
},
// noRepeat not specified
};
const result = await carto.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(true);
});
it('should preserve theme and label settings when noRepeat is set', async () => {
const mockDarkTheme = { isDark: true } as GrafanaTheme2;
const options: MapLayerOptions<CartoConfig> = {
name: 'Test CARTO Layer',
type: 'carto',
config: {
theme: LayerTheme.Auto, // Should use dark theme from mockDarkTheme
showLabels: false,
},
noRepeat: true,
};
const result = await carto.create(mockMap, options, mockEventBus, mockDarkTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source.getWrapX()).toBe(false);
// Check that the URL reflects the dark theme without labels
const urls = source.getUrls();
expect(urls?.[0]).toContain('dark_nolabels');
});
});

View File

@ -51,10 +51,13 @@ export const carto: MapLayerRegistryItem<CartoConfig> = {
style += '_nolabels';
}
const scale = window.devicePixelRatio > 1 ? '@2x' : '';
const noRepeat = options.noRepeat ?? false;
return new TileLayer({
source: new XYZ({
attributions: `<a href="https://carto.com/attribution/">©CARTO</a> <a href="https://www.openstreetmap.org/copyright">©OpenStreetMap</a> contributors`,
url: `https://{1-4}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}${scale}.png`,
wrapX: !noRepeat,
}),
});
},

View File

@ -0,0 +1,103 @@
import OpenLayersMap from 'ol/Map';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import { EventBus, GrafanaTheme2, MapLayerOptions } from '@grafana/data';
import { xyzTiles, XYZConfig } from './generic';
describe('XYZ tile layer noRepeat functionality', () => {
let mockMap: OpenLayersMap;
let mockEventBus: EventBus;
let mockTheme: GrafanaTheme2;
beforeEach(() => {
mockMap = {} as OpenLayersMap;
mockEventBus = {} as EventBus;
mockTheme = {} as GrafanaTheme2;
});
it('should set wrapX to false when noRepeat is true', async () => {
const options: MapLayerOptions<XYZConfig> = {
name: 'Test Layer',
type: 'xyz',
config: {
url: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test Attribution',
},
noRepeat: true,
};
const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(false);
});
it('should set wrapX to true when noRepeat is false', async () => {
const options: MapLayerOptions<XYZConfig> = {
name: 'Test Layer',
type: 'xyz',
config: {
url: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test Attribution',
},
noRepeat: false,
};
const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(true);
});
it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => {
const options: MapLayerOptions<XYZConfig> = {
name: 'Test Layer',
type: 'xyz',
config: {
url: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test Attribution',
},
// noRepeat not specified
};
const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
expect(source.getWrapX()).toBe(true);
});
it('should preserve other layer properties when noRepeat is set', async () => {
const options: MapLayerOptions<XYZConfig> = {
name: 'Test Layer',
type: 'xyz',
config: {
url: 'https://example.com/{z}/{x}/{y}.png',
attribution: 'Test Attribution',
minZoom: 2,
maxZoom: 18,
},
noRepeat: true,
};
const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
expect(layer.getMinZoom()).toBe(2);
expect(layer.getMaxZoom()).toBe(18);
const source = (layer as TileLayer<XYZ>).getSource() as XYZ;
expect(source.getWrapX()).toBe(false);
});
});

View File

@ -35,10 +35,13 @@ export const xyzTiles: MapLayerRegistryItem<XYZConfig> = {
cfg.url = defaultXYZConfig.url;
cfg.attribution = cfg.attribution ?? defaultXYZConfig.attribution;
}
const noRepeat = options.noRepeat ?? false;
return new TileLayer({
source: new XYZ({
url: cfg.url,
attributions: cfg.attribution, // singular?
wrapX: !noRepeat,
}),
minZoom: cfg.minZoom,
maxZoom: cfg.maxZoom,

View File

@ -0,0 +1,67 @@
import OpenLayersMap from 'ol/Map';
import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';
import { EventBus, MapLayerOptions, GrafanaTheme2 } from '@grafana/data';
import { standard } from './osm';
describe('OSM layer noRepeat functionality', () => {
let mockMap: OpenLayersMap;
let mockEventBus: EventBus;
let mockTheme: GrafanaTheme2;
beforeEach(() => {
mockMap = {} as OpenLayersMap;
mockEventBus = {} as EventBus;
mockTheme = {} as GrafanaTheme2;
});
it('should set wrapX to false when noRepeat is true', async () => {
const options: MapLayerOptions = {
name: 'Test OSM Layer',
type: 'osm-standard',
noRepeat: true,
};
const result = await standard.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<OSM>).getSource() as OSM;
expect(source).toBeInstanceOf(OSM);
expect(source.getWrapX()).toBe(false);
});
it('should set wrapX to true when noRepeat is false', async () => {
const options: MapLayerOptions = {
name: 'Test OSM Layer',
type: 'osm-standard',
noRepeat: false,
};
const result = await standard.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<OSM>).getSource() as OSM;
expect(source).toBeInstanceOf(OSM);
expect(source.getWrapX()).toBe(true);
});
it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => {
const options: MapLayerOptions = {
name: 'Test OSM Layer',
type: 'osm-standard',
// noRepeat not specified
};
const result = await standard.create(mockMap, options, mockEventBus, mockTheme);
const layer = result.init();
expect(layer).toBeInstanceOf(TileLayer);
const source = (layer as TileLayer<OSM>).getSource() as OSM;
expect(source).toBeInstanceOf(OSM);
expect(source.getWrapX()).toBe(true);
});
});

View File

@ -16,8 +16,10 @@ export const standard: MapLayerRegistryItem = {
*/
create: async (map: OpenLayersMap, options: MapLayerOptions, eventBus: EventBus) => ({
init: () => {
const noRepeat = options.noRepeat ?? false;
return new TileLayer({
source: new OSM(),
source: new OSM({ wrapX: !noRepeat }),
});
},
}),

View File

@ -248,4 +248,31 @@ describe('geomap migrations', () => {
}
`);
});
it('should handle migration when noRepeat is not set', () => {
const panel = {
id: 2,
type: 'geomap',
options: {
view: {
id: 'coords',
zoom: 5,
},
layers: [
{
type: 'markers',
config: {
showLegend: false,
},
},
],
},
pluginVersion: '8.2.0',
} as PanelModel;
panel.options = mapMigrationHandler(panel);
expect(panel.options.view.noRepeat).toBeUndefined();
expect(panel.options.view.id).toBe('coords');
expect(panel.options.view.zoom).toBe(5);
});
});

View File

@ -42,6 +42,14 @@ export const plugin = new PanelPlugin<Options>(GeomapPanel)
defaultValue: defaultMapViewConfig.shared,
});
builder.addBooleanSwitch({
category,
path: 'view.noRepeat',
name: t('geomap.name-no-repeat', 'No map repeating'),
description: t('geomap.description-no-repeat', 'Prevent the map from repeating horizontally'),
defaultValue: false,
});
// eslint-disable-next-line
const state = context.instanceState as GeomapInstanceState;
if (!state?.layers) {

View File

@ -45,6 +45,7 @@ composableKinds: PanelCfg: {
lastOnly?: bool
layer?: string
shared?: bool
noRepeat?: bool | *false
} @cuetsy(kind="interface")
ControlsOptions: {

View File

@ -31,6 +31,7 @@ export interface MapViewConfig {
lon?: number;
maxZoom?: number;
minZoom?: number;
noRepeat?: boolean;
padding?: number;
shared?: boolean;
zoom?: number;
@ -41,6 +42,7 @@ export const defaultMapViewConfig: Partial<MapViewConfig> = {
id: 'zero',
lat: 0,
lon: 0,
noRepeat: false,
zoom: 1,
};

View File

@ -7512,6 +7512,7 @@
},
"description-initial-view": "This location will show when the panel first loads.",
"description-mouse-wheel-zoom": "Enable zoom control via mouse wheel",
"description-no-repeat": "Prevent the map from repeating horizontally",
"description-share-view": "Use the same view across multiple panels. Note: this may require a dashboard reload.",
"description-show-attribution": "Show the map source attribution info in the lower right",
"description-show-debug": "Show map info",
@ -7571,6 +7572,7 @@
},
"name-initial-view": "Initial view",
"name-mouse-wheel-zoom": "Mouse wheel zoom",
"name-no-repeat": "No map repeating",
"name-share-view": "Share view",
"name-show-attribution": "Show attribution",
"name-show-debug": "Show debug",