import React, { useMemo, useState, useEffect } from 'react'; import type { FilterExpression } from '../utils/MapboxStyles'; import CircleLayer from './CircleLayer'; import RasterLayer from './RasterLayer'; import { SymbolLayer } from './SymbolLayer'; import LineLayer from './LineLayer'; import FillLayer from './FillLayer'; import FillExtrusionLayer from './FillExtrusionLayer'; import BackgroundLayer from './BackgroundLayer'; import HeatmapLayer from './HeatmapLayer'; import VectorSource from './VectorSource'; import RasterSource from './RasterSource'; import ImageSource from './ImageSource'; import { ShapeSource } from './ShapeSource'; function toCamelCase(s: string): string { return s.replace(/([-_][a-z])/gi, ($1) => { return $1.toUpperCase().replace('-', '').replace('_', ''); }); } // Patches the Mapbox Style Specification keys into the style props attributes: // icon-allow-overlap -> iconAllowOverlap function toCamelCaseKeys(oldObj?: { [key: string]: unknown }): { [key: string]: unknown; } { if (!oldObj) { return {}; } const newObj: { [key: string]: unknown } = {}; Object.keys(oldObj).forEach((key) => { const value = oldObj[key]; if (key.includes('-')) { newObj[toCamelCase(key)] = value; } else { newObj[key] = value; } }); return newObj; } function getLayerComponentType(layer: { type: string }) { const { type } = layer; switch (type) { case 'circle': return CircleLayer; case 'symbol': return SymbolLayer; case 'raster': return RasterLayer; case 'line': return LineLayer; case 'fill': return FillLayer; case 'fill-extrusion': return FillExtrusionLayer; case 'background': return BackgroundLayer; case 'heatmap': return HeatmapLayer; } console.warn(`Mapbox layer type '${type}' is not supported/`); return null; } function asLayerComponent(layer: MapboxJSONLayer) { type GenericProps = { key?: string; id: string }; const LayerComponent: typeof React.PureComponent | null = getLayerComponentType(layer) as typeof React.PureComponent; if (!LayerComponent) { return null; } const style = { ...toCamelCaseKeys(layer.paint), ...toCamelCaseKeys(layer.layout), }; const layerProps: { sourceID?: string; sourceLayerID?: string; minZoomLevel?: number; maxZoomLevel?: number; filter?: FilterExpression; style?: object; } = {}; if (layer.source) { layerProps.sourceID = layer.source; } if (layer['source-layer']) { layerProps.sourceLayerID = layer['source-layer']; } if (layer.minzoom) { layerProps.minZoomLevel = layer.minzoom; } if (layer.maxzoom) { layerProps.maxZoomLevel = layer.maxzoom; } if (layer.filter) { layerProps.filter = layer.filter; } if (Object.keys(style).length) { layerProps.style = style; } return ; } type SourceProps = { url?: string; tileUrlTemplates?: string[]; minZoomLevel?: number; maxZoomLevel?: number; attribution?: string; tms?: boolean; }; function getTileSourceProps(source: MapboxJSONSource): SourceProps { const sourceProps: SourceProps = {}; if (source.url) { sourceProps.url = source.url; } if (source.tiles) { sourceProps.tileUrlTemplates = source.tiles; } if (source.minzoom !== undefined) { sourceProps.minZoomLevel = source.minzoom; } if (source.maxzoom !== undefined) { sourceProps.maxZoomLevel = source.maxzoom; } if (source.attribution) { sourceProps.attribution = source.attribution; } if (source.scheme && source.scheme === 'tms') { sourceProps.tms = true; } return sourceProps; } function getVectorSource(id: string, source: MapboxJSONSource) { const sourceProps = { ...getTileSourceProps(source) }; return ; } function getRasterSource(id: string, source: MapboxJSONSource) { const sourceProps: { tileSize?: number } & SourceProps = { ...getTileSourceProps(source), }; if (source.tileSize) { sourceProps.tileSize = source.tileSize; } return ; } function getImageSource(id: string, source: MapboxJSONSource) { const sourceProps = { url: source.url, coordinates: source.coordinates, }; return ; } type ShapeShourceShape = (typeof ShapeSource.prototype.props)['shape']; function getShapeSource(id: string, source: MapboxJSONSource) { const sourceProps: { url?: string; shape?: ShapeShourceShape; cluster?: boolean; clusterRadius?: number; clusterMaxZoomLevel?: number; clusterProperties?: object; buffer?: number; tolerance?: number; lineMetrics?: boolean; } & SourceProps = {}; if (source.data && typeof source.data === 'string') { sourceProps.url = source.data; } else if (source.data && typeof source.data === 'object') { sourceProps.shape = source.data as ShapeShourceShape; } if (source.cluster !== undefined) { sourceProps.cluster = source.cluster; } if (source.clusterRadius !== undefined) { sourceProps.clusterRadius = source.clusterRadius; } if (source.maxzoom !== undefined) { sourceProps.maxZoomLevel = source.maxzoom; } if (source.clusterMaxZoom !== undefined) { sourceProps.clusterMaxZoomLevel = source.clusterMaxZoom; } if (source.clusterProperties !== undefined) { sourceProps.clusterProperties = source.clusterProperties; } if (source.buffer !== undefined) { sourceProps.buffer = source.buffer; } if (source.tolerance !== undefined) { sourceProps.tolerance = source.tolerance; } if (source.lineMetrics !== undefined) { sourceProps.lineMetrics = source.lineMetrics; } return ; } function asSourceComponent(id: string, source: MapboxJSONSource) { switch (source.type) { case 'vector': return getVectorSource(id, source); case 'raster': return getRasterSource(id, source); case 'image': return getImageSource(id, source); case 'geojson': return getShapeSource(id, source); } console.warn(`Mapbox source type '${source.type}' is not supported/`); return null; } type MapboxJSONLayer = { type: string; id: string; paint?: { [k: string]: unknown }; layout?: { [k: string]: unknown }; source?: string; minzoom?: number; maxzoom?: number; filter?: FilterExpression; style?: object; ['source-layer']: string; }; type MapboxJSONSource = { type: string; url?: string; tiles?: string[]; minzoom?: number; maxzoom?: number; attribution?: string; scheme?: string; tileSize?: number; coordinates?: [ [number, number], [number, number], [number, number], [number, number], ]; data?: string | object; buffer: number; cluster?: boolean; clusterRadius?: number; clusterMaxZoom?: number; clusterProperties?: object; tolerance?: number; lineMetrics?: boolean; }; type MapboxJSON = { layers?: MapboxJSONLayer[]; sources?: { [key: string]: MapboxJSONSource }; }; type Props = { /** * A JSON object conforming to the schema described in the Mapbox Style Specification , or a URL to such JSON. */ json: MapboxJSON | URL; }; /** * Style is a component that automatically adds sources / layers to the map using Mapbox GL Style Spec. * Only [`sources`](https://docs.mapbox.com/mapbox-gl-js/style-spec/sources) & [`layers`](https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/) are supported. * Other fields such as `sprites`, `glyphs` etc. will be ignored. Not all layer / source attributes from the style spec are supported, in general the supported attributes will be mentioned under https://github.com/rnmapbox/maps/tree/main/docs. */ const Style = (props: Props) => { const [fetchedJson, setFetchedJson] = useState({}); const json: MapboxJSON = typeof props.json === 'object' ? props.json : fetchedJson; // Fetch style when props.json is a URL useEffect(() => { const abortController = new AbortController(); const fetchStyleJson = async (styleJson: string) => { try { const response = await fetch(styleJson, { signal: abortController.signal, }); const responseJson = await response.json(); setFetchedJson(responseJson); } catch (error: unknown) { const e = error as { name?: string }; if (e.name === 'AbortError') { return; } throw e; } }; if (typeof props.json === 'string') { fetchStyleJson(props.json); } return function cleanup() { abortController.abort(); }; }, [props.json]); // Extract layer components from json const layerComponents = useMemo(() => { if (!json.layers) { return []; } return json.layers.map(asLayerComponent).filter((x) => !!x); }, [json.layers]); // Extract source components from json const { sources } = json; const sourceComponents = useMemo(() => { if (!sources || !Object.keys(sources)) { return []; } return Object.keys(sources) .map((id) => asSourceComponent(id, sources[id]!)) .filter((x) => !!x); }, [sources]); return ( <> {sourceComponents} {layerComponents} ); }; export default Style;