Geomap: Add a MapLibre style base layer (#109841)

* GeoMap: Add maplibre style basemap

* Docs: Add maplibre GeoMap base map

* GeoMap: Add test dashboard to devenv

* Add support for opacity

* Simplify layer name and description

* Add no repeat support

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/geomap/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Fix vale issues

* Move to consistent async await approach

* Increment ol-ext

* Remove mapbox temporarily

* Add mapbox back

* Add access token url to docs

* Highlight access token as public

* Make capitalization consistent

* Update yarn lock

* Grab yarn lock from main

* Add missing line to yarn lock

* Fix last line again

* Remove gdev dashboard for now

---------

Co-authored-by: Remo Geissbühler <37411349+remogeissbuehler@users.noreply.github.com>
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Drew Slobodnjak
2025-10-30 17:56:30 -07:00
committed by GitHub
co-authored by Isabel Matwawana Remo Geissbühler
parent 3965761168
commit f61578a50f
3 changed files with 164 additions and 3 deletions
@@ -212,7 +212,7 @@ You can add multiple layers of data to a single geomap in order to create rich,
#### Layer type
There are seven map layer types to choose from in a geomap.
There are eight map layer types to choose from in a geomap.
- [Markers](#markers-layer) renders a marker at each data point.
- [Heatmap](#heatmap-layer) visualizes a heatmap of the data.
@@ -225,6 +225,7 @@ There are seven map layer types to choose from in a geomap.
- [CARTO basemap](#carto-basemap-layer) adds a layer from CARTO Raster basemaps.
- [ArcGIS MapServer](#arcgis-mapserver-layer) adds a layer from an ESRI ArcGIS MapServer.
- [XYZ Tile layer](#xyz-tile-layer) adds a map from a generic tile layer.
- [MapLibre Style layer](#maplibre-style-layer) adds a map from a MapLibre/Mapbox style URL.
There are also two experimental (or alpha) layer types.
@@ -518,6 +519,13 @@ The XYZ Tile layer is a map from a generic tile layer.
- [Tiled Web Map Wikipedia](https://en.wikipedia.org/wiki/Tiled_web_map)
- [List of Open Street Map Tile Servers](https://wiki.openstreetmap.org/wiki/Tile_servers)
#### MapLibre Style layer
The MapLibre Style Layer is a map defined using a MapLibre/Mapbox `style.json` URL. The style contains the URL to the tiles, layer definitions, and more. Typically, they're based on vector tiles as opposed to raster tiles.
- **URL template** - Set a valid style URL. For example: `https://demotiles.maplibre.org/style.json`
- **Access Token** - An API token for mapbox maps. Only works for `mapbox://` URLs. Refer to [mapbox access tokens documentation](https://docs.mapbox.com/help/dive-deeper/access-tokens/) for more information. In other cases, you might have to include the token in the URL. For example: `https://example.com/map/style.json?key=XXX`.
### Basemap layer options
A basemap layer provides the visual foundation for a mapping application. It typically contains data with global coverage. Several base layer options
@@ -525,12 +533,13 @@ are available each with specific configuration options to style the base map.
Basemap layer types can also be added as layers. You can specify an opacity.
There are four basemap layer types to choose from in a geomap.
There are five basemap layer types to choose from in a geomap.
- [Open Street Map](#open-street-map-layer) adds a map from a collaborative free geographic world database.
- [CARTO basemap](#carto-basemap-layer) adds a layer from CARTO Raster basemaps.
- [ArcGIS MapServer](#arcgis-mapserver-layer) adds a layer from an ESRI ArcGIS MapServer.
- [XYZ Tile layer](#xyz-tile-layer) adds a map from a generic tile layer.
- [MapLibre Style layer](#maplibre-style-layer) adds a map from a MapLibre/Mapbox style URL.
The default basemap layer uses the CARTO map. You can define custom default base layers in the `.ini` configuration file.
@@ -540,7 +549,7 @@ The default basemap layer uses the CARTO map. You can define custom default base
You can configure the default base map using config files with Grafana’s provisioning system. For more information on all the settings, refer to the [provisioning docs page](ref:provisioning-docs-page).
Use the JSON configuration option `default_baselayer_config` to define the default base map. There are currently four base map options to choose from: `carto`, `esri-xyz`, `osm-standard`, `xyz`. Here are some provisioning examples for each base map option.
Use the JSON configuration option `default_baselayer_config` to define the default base map. There are currently five base map options to choose from: `carto`, `esri-xyz`, `osm-standard`, `xyz`, `maplibre`. Here are some provisioning examples for each base map option.
- **carto** loads the CartoDB tile server. You can choose from `auto`, `dark`, and `light` theme for the base map and can be set as shown below. The `showLabels` tag determines whether or not Grafana shows the Country details on top of the map. Here is an example:
@@ -606,6 +615,17 @@ default_baselayer_config = `{
}`
```
- **maplibre** loads a custom tile server defined by the user. Set a valid style `url` for this option to properly load a default base map. Here's an example:
```ini
default_baselayer_config = `{
"type": "maplibre",
"config": {
"url": "https://demotiles.maplibre.org/style.json"
}
}`
```
`enable_custom_baselayers` allows you to enable or disable custom open source base maps that are already implemented. The default is `true`.
### Map controls options
@@ -1,6 +1,7 @@
import { cartoLayers } from './carto';
import { esriLayers } from './esri';
import { genericLayers } from './generic';
import { maplibreLayers } from './maplibre';
import { osmLayers } from './osm';
/**
@@ -11,4 +12,5 @@ export const basemapLayers = [
...cartoLayers,
...esriLayers, // keep formatting
...genericLayers,
...maplibreLayers,
];
@@ -0,0 +1,139 @@
import Map from 'ol/Map';
import LayerGroup from 'ol/layer/Group';
import { apply } from 'ol-mapbox-style';
import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2, EventBus } from '@grafana/data';
// MapLibre Style Specification constants
const LAYER_TYPE_BACKGROUND = 'background';
const PAINT_BACKGROUND_OPACITY = 'background-opacity';
export interface MaplibreConfig {
url: string;
accessToken?: string;
}
const sampleURL = 'https://tiles.stadiamaps.com/styles/alidade_smooth.json';
export const defaultMaplibreConfig: MaplibreConfig = {
url: sampleURL,
};
interface ExtendedMapLayerOptions<T> extends MapLayerOptions<T> {
noRepeat?: boolean;
}
export const maplibreLayer: MapLayerRegistryItem<MaplibreConfig> = {
id: 'maplibre',
name: 'MapLibre layer',
description: 'Add layer using MapLibre style.json URL',
isBaseMap: true,
create: async (
map: Map,
options: ExtendedMapLayerOptions<MaplibreConfig>,
eventBus: EventBus,
theme: GrafanaTheme2
) => ({
init: () => {
const cfg = { ...options.config };
if (!cfg.url) {
cfg.url = defaultMaplibreConfig.url;
}
const layerOpacity = options.opacity ?? 1;
const noRepeat = options.noRepeat ?? false;
const layer = new LayerGroup({
opacity: layerOpacity,
});
const applyNoRepeat = () => {
if (noRepeat) {
// Set wrapX: false on the first layer source to prevent world repetition
const firstLayer = layer.getLayers().item(0);
if (firstLayer && 'getSource' in firstLayer && typeof firstLayer.getSource === 'function') {
const source = firstLayer.getSource();
if (source && 'setWrapX' in source && typeof source.setWrapX === 'function') {
source.setWrapX(false);
}
}
}
};
// Handle async operations in the background
const loadStyle = async () => {
try {
if (!cfg.url) {
console.warn('No URL provided for MapLibre style, layer will be empty');
return;
}
const res = await fetch(cfg.url);
if (!res.ok) {
console.warn(`Failed to load MapLibre style from ${cfg.url}: ${res.status} ${res.statusText}`);
// Try fallback approach
await tryFallbackApply();
return;
}
const style = await res.json();
// Adjust background opacity - let LayerGroup opacity handle everything else
if (Array.isArray(style?.layers)) {
for (const l of style.layers) {
if (l && l.type === LAYER_TYPE_BACKGROUND) {
l.paint = l.paint || {};
l.paint[PAINT_BACKGROUND_OPACITY] = layerOpacity;
}
}
}
await apply(layer, style, { styleUrl: cfg.url, accessToken: cfg.accessToken });
applyNoRepeat();
} catch (error) {
console.warn('Failed to parse or apply MapLibre style JSON:', error);
// Try fallback approach
await tryFallbackApply();
}
};
const tryFallbackApply = async () => {
try {
if (!cfg.url) {
console.warn('No URL available for MapLibre fallback, layer will be empty');
return;
}
await apply(layer, cfg.url, { accessToken: cfg.accessToken });
applyNoRepeat();
} catch (fallbackError) {
console.warn('Failed to load MapLibre style from both JSON and direct URL approaches:', fallbackError);
}
};
// Start loading the style asynchronously
loadStyle();
return layer;
},
registerOptionsUI: (builder) => {
builder
.addTextInput({
path: 'config.url',
name: 'URL template',
description: 'URL to the styles.json file.',
settings: {
placeholder: defaultMaplibreConfig.url,
},
})
.addTextInput({
path: 'config.accessToken',
name: 'Public access token',
description: 'Public access token for mapbox:// urls',
settings: {
placeholder: '',
},
});
},
}),
};
export const maplibreLayers = [maplibreLayer];