@opendatasoft/visualizations
Version:
Opendatasoft's components to easily build dashboards and visualizations.
231 lines (212 loc) • 7.55 kB
text/typescript
import type {
CircleLayerSpecification,
ExpressionSpecification,
MapOptions as MapLibreMapOptions,
StyleSpecification,
ExpressionInputType,
SymbolLayerSpecification,
FillLayerSpecification,
LineLayerSpecification,
} from 'maplibre-gl';
import { isGroupByForMatchExpression, Color } from 'types';
import type {
CircleLayer,
LayerSpecification,
Layer,
WebGlMapData,
WebGlMapOptions,
PopupConfigurationByLayers,
SymbolLayer,
CenterZoomOptions,
FillLayer,
LineLayer,
} from './types';
import { DEFAULT_DARK_GREY, DEFAULT_BASEMAP_STYLE, DEFAULT_SORT_KEY_VALUE } from './constants';
export const getMapStyle = (style: WebGlMapOptions['style']): MapLibreMapOptions['style'] => {
if (!style) return DEFAULT_BASEMAP_STYLE;
if (typeof style === 'string') return style;
return { ...DEFAULT_BASEMAP_STYLE, ...style };
};
export const getMapSources = (sources: WebGlMapData['sources']): StyleSpecification['sources'] => {
if (!sources) return DEFAULT_BASEMAP_STYLE.sources;
return sources;
};
const getBaseMapLayerConfiguration = (layer: Layer) => {
const { id, source, sourceLayer } = layer;
return {
id,
source,
...(sourceLayer ? { 'source-layer': sourceLayer } : null),
};
};
const getMapCircleLayer = (layer: CircleLayer): CircleLayerSpecification => {
const {
type,
circleRadius = 7,
circleStrokeWidth = 1.5,
colorMatch,
color: layerColor,
borderColor: layerBorderColor,
} = layer;
let circleColor: ExpressionSpecification | Color = layerColor;
let circleBorderColor: ExpressionSpecification | Color | undefined = layerBorderColor;
if (colorMatch) {
const { key, colors, borderColors } = colorMatch;
const matchExpression: ['match', ExpressionSpecification] = ['match', ['get', key]];
const groupByColors: ExpressionInputType[] = [];
Object.keys(colors).forEach((color) => {
groupByColors.push(color, colors[color]);
});
groupByColors.push(layerColor);
if (!isGroupByForMatchExpression(groupByColors)) {
throw new Error('Not the expected type for complete match expression');
}
circleColor = [...matchExpression, ...groupByColors];
if (borderColors) {
const matchBorderExpression: ['match', ExpressionSpecification] = [
'match',
['get', key],
];
const groupByBorderColors: ExpressionInputType[] = [];
Object.keys(borderColors).forEach((borderColor) => {
groupByBorderColors.push(borderColor, borderColors[borderColor]);
});
groupByBorderColors.push(circleBorderColor || DEFAULT_DARK_GREY);
if (!isGroupByForMatchExpression(groupByBorderColors)) {
throw new Error('Not the expected type for complete match expression');
}
circleBorderColor = [...matchBorderExpression, ...groupByBorderColors];
}
}
return {
...getBaseMapLayerConfiguration(layer),
filter: ['==', ['geometry-type'], 'Point'],
type,
paint: {
'circle-radius': circleRadius,
...(circleBorderColor && { 'circle-stroke-width': circleStrokeWidth }),
'circle-color': circleColor,
...(circleBorderColor && { 'circle-stroke-color': circleBorderColor }),
},
};
};
const getMapSymbolLayer = (layer: SymbolLayer): SymbolLayerSpecification => {
const { type, iconImageId, iconImageMatch } = layer;
let iconImage: Required<SymbolLayerSpecification>['layout']['icon-image'] = iconImageId;
if (iconImageMatch) {
const { key, imageIds } = iconImageMatch;
const matchExpression: ['match', ExpressionSpecification] = ['match', ['get', key]];
const groupByIconImages: ExpressionInputType[] = [];
Object.keys(imageIds).forEach((value) => {
groupByIconImages.push(value, imageIds[value]);
});
groupByIconImages.push(iconImageId);
if (!isGroupByForMatchExpression(groupByIconImages)) {
throw new Error('Not the expected type for complete match expression');
}
iconImage = [...matchExpression, ...groupByIconImages];
}
return {
...getBaseMapLayerConfiguration(layer),
filter: ['==', ['geometry-type'], 'Point'],
type,
layout: {
'icon-size': 1,
'icon-allow-overlap': true,
'icon-image': iconImage,
'symbol-sort-key': DEFAULT_SORT_KEY_VALUE,
},
};
};
const getMapFillLayer = (layer: FillLayer): FillLayerSpecification => {
const { type, color, borderColor, opacity } = layer;
return {
...getBaseMapLayerConfiguration(layer),
filter: ['==', ['geometry-type'], 'Polygon'],
type,
paint: {
'fill-color': color,
...(borderColor && { 'fill-outline-color': borderColor }),
...(opacity && { 'fill-opacity': opacity }),
},
};
};
const getMapLineLayer = (layer: LineLayer): LineLayerSpecification => {
const { type, color, width, opacity } = layer;
return {
...getBaseMapLayerConfiguration(layer),
filter: ['==', ['geometry-type'], 'LineString'],
type,
paint: {
'line-color': color,
...(width && { 'line-width': width }),
...(opacity && { 'line-opacity': opacity }),
},
};
};
// Circle, symbol and fill layers are supported
export const getMapLayers = (layers?: Layer[]): LayerSpecification[] => {
if (!layers) return [];
return layers.map((layer) => {
switch (layer.type) {
case 'circle':
return getMapCircleLayer(layer);
case 'symbol':
return getMapSymbolLayer(layer);
case 'fill':
return getMapFillLayer(layer);
case 'line':
return getMapLineLayer(layer);
default:
throw new Error(`Unexpected layer type for layer: ${layer}`);
}
});
};
export const getPopupConfigurationByLayers = (layers?: Layer[]): PopupConfigurationByLayers => {
const configurationByLayers: PopupConfigurationByLayers = {};
layers?.forEach(({ id, popup }) => {
if (popup) {
configurationByLayers[id] = popup;
}
});
return configurationByLayers;
};
export const getMapOptions = (options: WebGlMapOptions) => {
const {
bbox,
zoom,
maxZoom,
minZoom,
center,
interactive = true,
transformRequest,
cooperativeGestures,
preserveDrawingBuffer = false,
images,
} = options;
return {
bbox,
zoom,
minZoom,
maxZoom,
center,
interactive,
transformRequest,
cooperativeGestures,
preserveDrawingBuffer,
images,
};
};
/**
* Generates a valid CenterZoomOptions object by combining optional zoom and center properties.
*
* @param options - An object with optional zoom and center properties.
* @returns A CenterZoomOptions object with valid zoom and center properties is defined.
*/
export const getCenterZoomOptions: (options: CenterZoomOptions) => CenterZoomOptions = ({
zoom,
center,
}) => ({
...(center ? { center } : null),
...(Number.isInteger(zoom) ? { zoom } : null),
});