From deeb5790e6c2cae782353f1fa37293f203d54e44 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:10:12 -0800 Subject: [PATCH] Geomap: Add esri custom dynamic mapserver option --- .../visualizations/geomap/index.md | 19 ++ .../panel/geomap/layers/basemaps/esri.test.ts | 205 ++++++++++++++++++ .../panel/geomap/layers/basemaps/esri.ts | 92 +++++++- 3 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/esri.test.ts diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md index 209df209a77..ab636c38d17 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md @@ -496,9 +496,28 @@ An ArcGIS layer is a layer from an ESRI ArcGIS MapServer. - Custom MapServer (see [XYZ](#xyz-tile-layer) for formatting) - URL template - Attribution + - Refresh on update (reload tiles when dashboard refreshes) + - Custom Dynamic MapServer + - URL template (URL to ArcGIS MapServer, for example `https://mapservices.weather.noaa.gov/.../MapServer`) + - Attribution - **Opacity** from 0 (transparent) to 1 (opaque) - **Display tooltip** - allows you to toggle tooltips for the layer. +##### Custom Dynamic MapServer + +Custom Dynamic MapServer renders map images on-demand rather than using pre-cached tiles. +This is useful for real-time data that updates frequently, such as weather radar or other dynamic datasets. + +Dynamic services automatically refresh when the dashboard updates, ensuring you always see the latest data. +This differs from the Custom MapServer option, which uses pre-cached tiles and requires the "Refresh on update" option to be enabled for updates. + +**Example use cases:** + +- NOAA weather radar services +- Real-time traffic data +- Live sensor data overlays +- Any ArcGIS MapServer or ImageServer without tile caching + ##### More Information - [ArcGIS Services](https://services.arcgisonline.com/arcgis/rest/services) diff --git a/public/app/plugins/panel/geomap/layers/basemaps/esri.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/esri.test.ts new file mode 100644 index 00000000000..ce480fcae78 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/esri.test.ts @@ -0,0 +1,205 @@ +import OpenLayersMap from 'ol/Map'; +import ImageLayer from 'ol/layer/Image'; +import TileLayer from 'ol/layer/Tile'; +import ImageArcGISRest from 'ol/source/ImageArcGISRest'; +import XYZ from 'ol/source/XYZ'; + +import { EventBus, GrafanaTheme2, MapLayerOptions, PanelData } from '@grafana/data'; + +import { esriXYZTiles, ESRIXYZConfig } from './esri'; + +describe('ArcGIS MapServer layer', () => { + let mockMap: OpenLayersMap; + let mockEventBus: EventBus; + let mockTheme: GrafanaTheme2; + + beforeEach(() => { + mockMap = {} as OpenLayersMap; + mockEventBus = {} as EventBus; + mockTheme = {} as GrafanaTheme2; + }); + + describe('Custom MapServer (tiled)', () => { + it('should create a TileLayer with XYZ source for tiled service', async () => { + const options: MapLayerOptions = { + name: 'Test Tiled Layer', + type: 'esri-xyz', + config: { + server: 'custom', + url: 'https://example.com/arcgis/rest/services/MyService/MapServer/tile/{z}/{y}/{x}', + attribution: 'Test Attribution', + }, + }; + + const result = await esriXYZTiles.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); + }); + + it('should support refresh on update for tiled service', async () => { + const options: MapLayerOptions = { + name: 'Test Tiled Layer', + type: 'esri-xyz', + config: { + server: 'custom', + url: 'https://example.com/tile/{z}/{y}/{x}', + attribution: 'Test', + refreshOnUpdate: true, + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + const source = (layer as TileLayer).getSource() as XYZ; + + const refreshSpy = jest.spyOn(source, 'refresh'); + result.update?.({} as PanelData); + + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should not refresh when refreshOnUpdate is false', async () => { + const options: MapLayerOptions = { + name: 'Test Tiled Layer', + type: 'esri-xyz', + config: { + server: 'custom', + url: 'https://example.com/tile/{z}/{y}/{x}', + attribution: 'Test', + refreshOnUpdate: false, + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + const source = (layer as TileLayer).getSource() as XYZ; + + const refreshSpy = jest.spyOn(source, 'refresh'); + result.update?.({} as PanelData); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Custom Dynamic MapServer', () => { + it('should create an ImageLayer with ImageArcGISRest source for dynamic service', async () => { + const options: MapLayerOptions = { + name: 'Test Dynamic Layer', + type: 'esri-xyz', + config: { + server: 'custom-dynamic', + url: 'https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity/MapServer', + attribution: 'NOAA', + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(ImageLayer); + const source = (layer as ImageLayer).getSource() as ImageArcGISRest; + expect(source).toBeInstanceOf(ImageArcGISRest); + }); + + it('should strip /tile/{z}/{y}/{x} from dynamic service URL', async () => { + const options: MapLayerOptions = { + name: 'Test Dynamic Layer', + type: 'esri-xyz', + config: { + server: 'custom-dynamic', + url: 'https://example.com/MapServer/tile/{z}/{y}/{x}', + attribution: 'Test', + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + const source = (layer as ImageLayer).getSource() as ImageArcGISRest; + + // URL should be cleaned up + expect(source).toBeInstanceOf(ImageArcGISRest); + }); + + it('should always refresh dynamic service on update', async () => { + const options: MapLayerOptions = { + name: 'Test Dynamic Layer', + type: 'esri-xyz', + config: { + server: 'custom-dynamic', + url: 'https://example.com/MapServer', + attribution: 'Test', + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + const source = (layer as ImageLayer).getSource() as ImageArcGISRest; + + const refreshSpy = jest.spyOn(source!, 'refresh'); + result.update?.({} as PanelData); + + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should handle empty URL gracefully', async () => { + const options: MapLayerOptions = { + name: 'Test Dynamic Layer', + type: 'esri-xyz', + config: { + server: 'custom-dynamic', + url: '', + attribution: 'Test', + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(ImageLayer); + const source = (layer as ImageLayer).getSource(); + expect(source).toBeNull(); + }); + }); + + describe('Built-in services', () => { + it('should configure URL for built-in World Street Map service', async () => { + const options: MapLayerOptions = { + name: 'Test Built-in Layer', + type: 'esri-xyz', + config: { + server: 'streets', + url: '', + attribution: '', + }, + }; + + const result = await esriXYZTiles.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); + }); + + it('should use tiled layer for built-in services', async () => { + const options: MapLayerOptions = { + name: 'Test World Imagery', + type: 'esri-xyz', + config: { + server: 'world-imagery', + url: '', + attribution: '', + }, + }; + + const result = await esriXYZTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + expect(layer).not.toBeInstanceOf(ImageLayer); + }); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts index b19e0e0679d..09916fb4e83 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts @@ -1,14 +1,28 @@ import OpenLayersMap from 'ol/Map'; +import ImageLayer from 'ol/layer/Image'; +import TileLayer from 'ol/layer/Tile'; +import ImageArcGISRest from 'ol/source/ImageArcGISRest'; +import XYZ from 'ol/source/XYZ'; -import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2, RegistryItem, Registry, EventBus } from '@grafana/data'; +import { + MapLayerRegistryItem, + MapLayerOptions, + GrafanaTheme2, + RegistryItem, + Registry, + EventBus, + PanelData, + textUtil, +} from '@grafana/data'; -import { xyzTiles, defaultXYZConfig, XYZConfig } from './generic'; +import { defaultXYZConfig, XYZConfig } from './generic'; interface PublicServiceItem extends RegistryItem { slug: string; } const CUSTOM_SERVICE = 'custom'; +const CUSTOM_DYNAMIC_SERVICE = 'custom-dynamic'; const DEFAULT_SERVICE = 'streets'; export const publicServiceRegistry = new Registry(() => [ @@ -48,10 +62,17 @@ export const publicServiceRegistry = new Registry(() => [ description: 'Use a custom MapServer with pre-cached values', slug: '', }, + { + id: CUSTOM_DYNAMIC_SERVICE, + name: 'Custom Dynamic MapServer', + description: 'Use a custom MapServer with dynamic values', + slug: '', + }, ]); export interface ESRIXYZConfig extends XYZConfig { server: string; + refreshOnUpdate?: boolean; } export const esriXYZTiles: MapLayerRegistryItem = { @@ -68,21 +89,45 @@ export const esriXYZTiles: MapLayerRegistryItem = { ) => { const cfg = { ...options.config }; const svc = publicServiceRegistry.getIfExists(cfg.server ?? DEFAULT_SERVICE)!; - if (svc.id !== CUSTOM_SERVICE) { + const noRepeat = options.noRepeat ?? false; + const useDynamic = svc.id === CUSTOM_DYNAMIC_SERVICE; + + // Configure URL for built-in services + if (svc.id !== CUSTOM_SERVICE && svc.id !== CUSTOM_DYNAMIC_SERVICE) { const base = 'https://services.arcgisonline.com/ArcGIS/rest/services/'; cfg.url = `${base}${svc.slug}/MapServer/tile/{z}/{y}/{x}`; cfg.attribution = `Tiles © ArcGIS`; } - const opts = { ...options, config: cfg as XYZConfig }; - return xyzTiles.create(map, opts, eventBus, theme).then((xyz) => { - xyz.registerOptionsUI = (builder) => { + + // Create layer based on service type + let layer; + let source; + + if (useDynamic) { + const baseUrl = cfg.url ? cfg.url.replace(/\/tile\/\{z\}\/\{y\}\/\{x\}$/, '') : ''; + const sanitizedUrl = baseUrl ? textUtil.sanitizeUrl(baseUrl) : ''; + source = sanitizedUrl + ? new ImageArcGISRest({ url: sanitizedUrl, params: {}, ratio: 1, attributions: cfg.attribution }) + : undefined; + layer = new ImageLayer({ source }); + } else { + source = new XYZ({ url: cfg.url, attributions: cfg.attribution, wrapX: !noRepeat }); + layer = new TileLayer({ source, minZoom: cfg.minZoom, maxZoom: cfg.maxZoom }); + } + + return { + init: () => layer, + update: (data: PanelData) => { + if ((useDynamic || cfg.refreshOnUpdate) && source) { + source.refresh(); + } + }, + registerOptionsUI: (builder) => { builder .addSelect({ path: 'config.server', name: 'Server instance', - settings: { - options: publicServiceRegistry.selectOptions().options, - }, + settings: { options: publicServiceRegistry.selectOptions().options }, }) .addTextInput({ path: 'config.url', @@ -100,10 +145,33 @@ export const esriXYZTiles: MapLayerRegistryItem = { placeholder: defaultXYZConfig.attribution, }, showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, + }) + .addBooleanSwitch({ + path: 'config.refreshOnUpdate', + name: 'Refresh on update', + description: 'Reload tiles when dashboard refreshes', + defaultValue: false, + showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, + }) + .addTextInput({ + path: 'config.url', + name: 'URL template', + description: 'URL to ArcGIS MapServer', + settings: { + placeholder: 'https://example.com/arcgis/rest/services/MyService/MapServer', + }, + showIf: (cfg) => cfg.config?.server === CUSTOM_DYNAMIC_SERVICE, + }) + .addTextInput({ + path: 'config.attribution', + name: 'Attribution', + settings: { + placeholder: defaultXYZConfig.attribution, + }, + showIf: (cfg) => cfg.config?.server === CUSTOM_DYNAMIC_SERVICE, }); - }; - return xyz; - }); + }, + }; }, defaultOptions: {