diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md index fe941617bce..209df209a77 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md @@ -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 diff --git a/public/app/plugins/panel/geomap/layers/basemaps/index.ts b/public/app/plugins/panel/geomap/layers/basemaps/index.ts index bed65e126f5..99ddde8622b 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/index.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/index.ts @@ -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, ]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/maplibre.ts b/public/app/plugins/panel/geomap/layers/basemaps/maplibre.ts new file mode 100644 index 00000000000..2d8ec04efd4 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/maplibre.ts @@ -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 extends MapLayerOptions { + noRepeat?: boolean; +} + +export const maplibreLayer: MapLayerRegistryItem = { + id: 'maplibre', + name: 'MapLibre layer', + description: 'Add layer using MapLibre style.json URL', + isBaseMap: true, + + create: async ( + map: Map, + options: ExtendedMapLayerOptions, + 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];