UNPKG

@opendatasoft/visualizations

Version:

Opendatasoft's components to easily build dashboards and visualizations.

299 lines (273 loc) 10.7 kB
import chroma from 'chroma-js'; import turfBbox from '@turf/bbox'; import maplibregl, { ExpressionInputType, ExpressionSpecification } from 'maplibre-gl'; import { viewport } from '@placemarkio/geo-viewport'; import type { Feature, FeatureCollection, Position, BBox } from 'geojson'; import type { Scale } from 'chroma-js'; import { assertUnreachable } from 'components/utils'; import { Color, ColorScale, DataBounds, isGroupByForMatchExpression, ColorScaleTypes } from 'types'; import { DEFAULT_COLORS } from './constants'; import type { ChoroplethDataValue, ChoroplethFixedTooltipDescription, MapFilter, ChoroplethTooltipFormatter, MapRenderTooltipFunction, ComputeTooltipFunction, ChoroplethLayer, } from './types'; import { ChoroplethTooltipMatcherTypes, TooltipParams } from './types'; export const LIGHT_GREY: Color = '#CBD2DB'; export const DARK_GREY: Color = '#515457'; export function getDataBounds(values: ChoroplethDataValue[]): DataBounds { const rawValues = values.map((v) => v.y); const min = Math.min(...rawValues); const max = Math.max(...rawValues); return { min, max }; } export const colorShapes = ({ featureCollection, colorMapping, emptyValueColor, }: { featureCollection: FeatureCollection; colorMapping: { [key: string]: Color }; emptyValueColor: Color; }) => { const coloredFeatures = featureCollection.features.map((feature: Feature) => { const color = colorMapping[feature?.properties?.key] || emptyValueColor; return { ...feature, properties: { ...feature.properties, color, }, }; }); const coloredShapes: FeatureCollection = { type: 'FeatureCollection', features: coloredFeatures, }; return coloredShapes; }; export const mapKeyToColor = ( values: ChoroplethDataValue[], dataBounds: DataBounds, colorScale: ColorScale, emptyValueColor: Color = DEFAULT_COLORS.Default ): { [s: string]: string } => { const { min, max } = dataBounds; let colorMin: Color; let colorMax: Color; let scale: Scale; // This is an exhaustive check, function must handle all color scale types switch (colorScale.type) { case ColorScaleTypes.Palette: { const thresholdArray: number[] = []; colorScale.colors.forEach((_color: Color, i: number) => { if (i === 0) { thresholdArray.push(min); thresholdArray.push(min + (max - min) / colorScale.colors.length); } else if (i === colorScale.colors.length - 1) { thresholdArray.push(max); } else { thresholdArray.push(min + ((max - min) / colorScale.colors.length) * (i + 1)); } }); scale = chroma.scale(colorScale.colors).classes(thresholdArray); break; } case ColorScaleTypes.Gradient: colorMin = chroma(colorScale.colors.start).hex(); colorMax = chroma(colorScale.colors.end).hex(); scale = chroma.scale([colorMin, colorMax]).domain([min, max]); break; default: { // This function should never be reached because of the exhaustive check (will throw a compilation error) const exhaustiveCheck: never = colorScale; assertUnreachable(exhaustiveCheck); } } const dataMapping: { [s: ChoroplethDataValue['x']]: Color } = {}; values.forEach(({ x, y }) => { dataMapping[x] = Number.isFinite(y) ? scale(y).hex() : emptyValueColor; }); return dataMapping; }; // This is a default bound that will be extended export const VOID_BOUNDS: BBox = [180, 90, -180, -90]; type CoordsPath = Position[]; function computeBboxFromCoords(coordsPath: CoordsPath, bbox: BBox): BBox { return coordsPath.reduce<BBox>( (current: BBox, coords: Position) => [ Math.min(coords[0], current[0]), Math.min(coords[1], current[1]), Math.max(coords[0], current[2]), Math.max(coords[1], current[3]), ], bbox ); } // The features given by querySourceFeatures are cut based on a tile representation // but we need the bounding box of the features themselves, so we need to build them again function mergeBboxFromFeaturesWithSameKey(features: Feature[], matchKey: string) { const mergedBboxes: { [key: string]: { bbox: BBox; }; } = {}; features.forEach((feature) => { // FIXME: supports only shapes for now if (feature.geometry.type === 'Polygon') { // Compute extent first let bbox = VOID_BOUNDS; feature.geometry.coordinates.forEach((coordsPath) => { bbox = computeBboxFromCoords(coordsPath, bbox); }); const id: string = feature.properties?.[matchKey]; if (!mergedBboxes[id]) { mergedBboxes[id] = { bbox }; } else { const storedBbox = mergedBboxes[id].bbox; const mergedBbox: BBox = [ Math.min(bbox[0], storedBbox[0]), Math.min(bbox[1], storedBbox[1]), Math.max(bbox[2], storedBbox[2]), Math.max(bbox[3], storedBbox[3]), ]; // Replace the Features at the right id by the merged bbox mergedBboxes[id] = { bbox: mergedBbox, }; } } }); return mergedBboxes; } // We're calculating the maximum zoom required to fit the smallest feature we're displaying, to prevent people from zooming "too far" by accident export const computeMaxZoomFromGeoJsonFeatures = ( mapContainer: HTMLElement, features: Feature[], matchKey: string ): number => { let maxZoom = 0; // maxZoom lowest value possible const filteredBboxes = mergeBboxFromFeaturesWithSameKey(features, matchKey); // FIXME: any // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.values(filteredBboxes).forEach((value: any) => { // Vtiles = 512 tile size maxZoom = Math.max( viewport(value.bbox, [mapContainer.clientWidth, mapContainer.clientHeight], { tileSize: 512, }).zoom, maxZoom ); }); return maxZoom; }; const getShapeCenter = (feature: Feature) => { const featureBbox = turfBbox(feature.geometry); const centerLatitude = (featureBbox[1] + featureBbox[3]) / 2; const centerLongitude = (featureBbox[0] + featureBbox[2]) / 2; return [centerLongitude, centerLatitude]; }; export const getFixedTooltips = ( shapeKeys: string[], features: Feature[], renderTooltip: MapRenderTooltipFunction, matchKey: string ): ChoroplethFixedTooltipDescription[] => { const popups = shapeKeys.map((shapeKey) => { const matchedFeature = features.find( (feature) => feature.properties?.[matchKey] === shapeKey ); if (matchedFeature) { const center = getShapeCenter(matchedFeature); const description = renderTooltip(matchedFeature); // Cancel the debounce on activeShapes renderTooltip.cancel(); const popup = new maplibregl.Popup({ closeOnClick: false, closeButton: false, className: 'tooltip-on-hover', }); return { center, description, popup }; } return null; }); return popups.filter((item): item is NonNullable<ChoroplethFixedTooltipDescription> => Boolean(item) ) as ChoroplethFixedTooltipDescription[]; }; /** Transform a filter object from the options into a Maplibre filter expression */ export const computeFilterExpression = (filterConfig: MapFilter): ExpressionSpecification => { const { key, value } = filterConfig; const filterMatchExpression: ExpressionSpecification = [ 'in', ['downcase', ['get', key]], ['literal', []], ]; const matchingValues: string[] = Array.isArray(value) ? value.map((v) => v.toString().toLowerCase()) : [value.toString().toLowerCase()]; filterMatchExpression[2] = ['literal', matchingValues]; return filterMatchExpression; }; export const defaultTooltipFormat: ChoroplethTooltipFormatter = ({ value, label }) => value ? `${label} &mdash; ${value}` : label; export const computeTooltip: ComputeTooltipFunction = ( hoveredFeature, dataValues, options, matchKey ) => { const values = dataValues || []; const matchedFeature = values.find( (item: ChoroplethDataValue) => String(item.x) === hoveredFeature.properties?.[matchKey] ); let tooltipLabel = hoveredFeature.properties?.label || hoveredFeature.properties?.[matchKey]; const labelMatcher = options?.tooltip?.labelMatcher; if (labelMatcher) { const { type } = labelMatcher; if (type === ChoroplethTooltipMatcherTypes.KeyProperty) { const { key } = labelMatcher; tooltipLabel = hoveredFeature.properties?.[key]; } else if (type === ChoroplethTooltipMatcherTypes.KeyMap && matchedFeature) { const { mapping } = labelMatcher; tooltipLabel = mapping[matchedFeature?.x]; } } const tooltipRawValues: TooltipParams = { value: matchedFeature?.y, label: tooltipLabel, key: hoveredFeature.properties?.[matchKey], // === matchedFeature.x }; const format = options?.tooltip?.formatter; return format ? format(tooltipRawValues) : defaultTooltipFormat(tooltipRawValues); }; export const computeBaseLayer = ( fillColor: string | ExpressionSpecification, DefaultColor: Color ): ChoroplethLayer => ({ type: 'fill', layout: {}, paint: { 'fill-color': fillColor, 'fill-opacity': 1, 'fill-outline-color': DefaultColor, }, }); export const computeMatchExpression = ( colors: { [s: string]: string }, matchKey: string, emptyValueColor: Color ): ExpressionSpecification => { const matchExpression: ['match', ExpressionSpecification] = ['match', ['get', matchKey]]; const groupByColors: ExpressionInputType[] = []; Object.entries(colors).forEach((e) => groupByColors.push(...e)); groupByColors.push(emptyValueColor); // Default fallback color if (!isGroupByForMatchExpression(groupByColors)) { throw new Error('Not the expected type for complete match expression'); } return [...matchExpression, ...groupByColors]; };