diff --git a/docs/sources/panels-visualizations/visualizations/geomap/index.md b/docs/sources/panels-visualizations/visualizations/geomap/index.md index fe8dbd11e65..3a10dea71d6 100644 --- a/docs/sources/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/panels-visualizations/visualizations/geomap/index.md @@ -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. diff --git a/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts b/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts index 17779dde1c9..803e2e7042d 100644 --- a/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts +++ b/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts @@ -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'); diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts index 0a085d8ec6e..fa1e3eaf299 100644 --- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts @@ -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 = { id: 'zero', lat: 0, lon: 0, + noRepeat: false, zoom: 1, }; diff --git a/packages/grafana-schema/src/veneer/common.types.ts b/packages/grafana-schema/src/veneer/common.types.ts index fb3bfe18c57..7a119bdb1af 100644 --- a/packages/grafana-schema/src/veneer/common.types.ts +++ b/packages/grafana-schema/src/veneer/common.types.ts @@ -6,6 +6,8 @@ export interface MapLayerOptions 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 { diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 14399a2893d..6984228c6f7 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -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 { 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 { 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 { * * 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 { 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 { }; 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) { diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts new file mode 100644 index 00000000000..88e3f95d9a3 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts @@ -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 = { + 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).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 = { + 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).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 = { + 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).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 = { + 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).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'); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts index 92f3c54502f..3e2572a702f 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts @@ -51,10 +51,13 @@ export const carto: MapLayerRegistryItem = { style += '_nolabels'; } const scale = window.devicePixelRatio > 1 ? '@2x' : ''; + const noRepeat = options.noRepeat ?? false; + return new TileLayer({ source: new XYZ({ attributions: `©CARTO ©OpenStreetMap contributors`, url: `https://{1-4}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}${scale}.png`, + wrapX: !noRepeat, }), }); }, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts new file mode 100644 index 00000000000..180a2be2644 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts @@ -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 = { + 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).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 = { + 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).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 = { + 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).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 = { + 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).getSource() as XYZ; + expect(source.getWrapX()).toBe(false); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts index 2ed2a022037..f3381902e26 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts @@ -35,10 +35,13 @@ export const xyzTiles: MapLayerRegistryItem = { 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, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts new file mode 100644 index 00000000000..19a62533b2a --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts @@ -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).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).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).getSource() as OSM; + expect(source).toBeInstanceOf(OSM); + expect(source.getWrapX()).toBe(true); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts index c644702b1ca..078ddafff43 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts @@ -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 }), }); }, }), diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts index eda219ea9ec..6cf672211dd 100644 --- a/public/app/plugins/panel/geomap/migrations.test.ts +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -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); + }); }); diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index f8c7825f896..7573f9317aa 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -42,6 +42,14 @@ export const plugin = new PanelPlugin(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) { diff --git a/public/app/plugins/panel/geomap/panelcfg.cue b/public/app/plugins/panel/geomap/panelcfg.cue index 67d12b9293a..7384ec581e5 100644 --- a/public/app/plugins/panel/geomap/panelcfg.cue +++ b/public/app/plugins/panel/geomap/panelcfg.cue @@ -45,6 +45,7 @@ composableKinds: PanelCfg: { lastOnly?: bool layer?: string shared?: bool + noRepeat?: bool | *false } @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 7e4288bf050..cffd8e01e13 100644 --- a/public/app/plugins/panel/geomap/panelcfg.gen.ts +++ b/public/app/plugins/panel/geomap/panelcfg.gen.ts @@ -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 = { id: 'zero', lat: 0, lon: 0, + noRepeat: false, zoom: 1, }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d0aa51d51e2..f9f5d8553c8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",