Geomap: Add esri custom dynamic mapserver option
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user