Geomap: Add esri custom dynamic mapserver option

This commit is contained in:
Drew Slobodnjak
2026-01-13 12:10:12 -08:00
parent 82d8d44977
commit deeb5790e6
3 changed files with 304 additions and 12 deletions
@@ -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)
@@ -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<ESRIXYZConfig> = {
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<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
});
it('should support refresh on update for tiled service', async () => {
const options: MapLayerOptions<ESRIXYZConfig> = {
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<XYZ>).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<ESRIXYZConfig> = {
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<XYZ>).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<ESRIXYZConfig> = {
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<ImageArcGISRest>).getSource() as ImageArcGISRest;
expect(source).toBeInstanceOf(ImageArcGISRest);
});
it('should strip /tile/{z}/{y}/{x} from dynamic service URL', async () => {
const options: MapLayerOptions<ESRIXYZConfig> = {
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<ImageArcGISRest>).getSource() as ImageArcGISRest;
// URL should be cleaned up
expect(source).toBeInstanceOf(ImageArcGISRest);
});
it('should always refresh dynamic service on update', async () => {
const options: MapLayerOptions<ESRIXYZConfig> = {
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<ImageArcGISRest>).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<ESRIXYZConfig> = {
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<ImageArcGISRest>).getSource();
expect(source).toBeNull();
});
});
describe('Built-in services', () => {
it('should configure URL for built-in World Street Map service', async () => {
const options: MapLayerOptions<ESRIXYZConfig> = {
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<XYZ>).getSource() as XYZ;
expect(source).toBeInstanceOf(XYZ);
});
it('should use tiled layer for built-in services', async () => {
const options: MapLayerOptions<ESRIXYZConfig> = {
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);
});
});
});
@@ -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<PublicServiceItem>(() => [
@@ -48,10 +62,17 @@ export const publicServiceRegistry = new Registry<PublicServiceItem>(() => [
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<ESRIXYZConfig> = {
@@ -68,21 +89,45 @@ export const esriXYZTiles: MapLayerRegistryItem<ESRIXYZConfig> = {
) => {
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 © <a href="${base}${svc.slug}/MapServer">ArcGIS</a>`;
}
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<ESRIXYZConfig> = {
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: {