UNPKG

s2maps-gpu

Version:

S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.

574 lines (573 loc) 20.3 kB
/** * Convert a MapLibre style to an s2maps {@link StyleDefinition} * * ex. * ```ts * import { convertMaplibreStyle } from 's2maps-gpu/plugins'; * import type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec'; * // setup maplibre style * const maplibreStyle: StyleSpecification = { ... }; * // convert to s2maps style * const s2mapsStyle = convertMaplibreStyle(maplibreStyle); * // create a map with it * const map = new S2Map({ ..., style: s2mapsStyle }); * ``` * @param input - the MapLibre style * @returns the s2maps style */ export function convertMaplibreStyle(input) { const { center, zoom, bearing, pitch, sprite, sources } = input; const glyphs = {}; const res = { version: 1, view: { lon: center?.[0], lat: center?.[1], zoom, bearing, pitch, }, projection: 'WM', glyphs, sources: convertSources(sources), sprites: sprite !== undefined ? convertSprite(sprite) : undefined, layers: input.layers .map((l) => convertLayer(l, glyphs)) .filter((l) => l !== undefined), }; return res; } /** * Convert source inputs * @param input - the MapLibre input sources * @returns the s2maps sources */ function convertSources(input) { const sources = {}; for (const [name, value] of Object.entries(input)) { if (typeof value === 'string') { sources[name] = value; continue; } const { type } = value; if (type === 'video' || type === 'image') { console.error(`Source type ${type} not supported`); continue; } if (type === 'geojson') { const { data, maxzoom, cluster, clusterRadius, clusterMaxZoom } = value; sources[name] = { path: '', extension: 'geojson', data: data, type: 'json', maxzoom, cluster, radius: clusterRadius, indexMaxzoom: clusterMaxZoom, }; } else { const { url, tiles, minzoom, maxzoom, bounds } = value; if (url === undefined && !Array.isArray(tiles)) throw new Error(`Source ${name} has no url`); const path = url ?? (Array.isArray(tiles) ? (tiles[0] ?? '') : ''); const extension = path.split('.').pop(); if (extension === 'json' && type === 'vector') { // remove the 'tiles.json' from the path sources[name] = path.replace('tiles.json', ''); } else { sources[name] = { path, extension: extension, type, minzoom, maxzoom, bounds, scheme: 'xyz', }; } } } return sources; } /** * Convert spite specific inputs * @param input - the MapLibre input sprite * @returns the s2maps sprite */ function convertSprite(input) { const res = {}; for (const sprite of input) { if (typeof sprite === 'string') res.default = sprite; else res[sprite.id] = sprite.url; } return res; } // TODO: symbol, fill-extrusion /** * Convert a MapLibre layer to an s2maps layer * @param layer - the MapLibre layer * @param glyphs - associated glyphs to inject into * @returns the s2maps layer */ function convertLayer(layer, glyphs) { const { type } = layer; if (type === 'background') return convertLayerBackground(layer); else if (type === 'fill') return convertLayerFill(layer); else if (type === 'line') return convertLayerLine(layer); else if (type === 'symbol') return convertLayerSymbol(layer, glyphs); else if (type === 'raster') return convertLayerRaster(layer); else if (type === 'circle') return convertLayerCircle(layer); // else if (type === 'fill-extrusion') return undefined else if (type === 'heatmap') return convertLayerHeatmap(layer); else if (type === 'hillshade') return convertLayerHillshade(layer); // else if (type === 'sky') return undefined else console.error(`Layer type ${type} not supported`); } // TODO: background-pattern /** * Convert a MapLibre background layer * @param backgroundLayer - the MapLibre background layer * @returns the s2maps background layer */ function convertLayerBackground(backgroundLayer) { const { id, metadata, minzoom, maxzoom, layout = {}, paint = {} } = backgroundLayer; return { name: id, type: 'fill', source: 'mask', metadata, minzoom, maxzoom, color: convertPropertyValueSpecification(paint['background-color']), opacity: convertPropertyValueSpecification(paint['background-opacity']), visible: layout.visibility !== 'none', }; } // TODO: PAINT: fill-antialias, fill-outline-color, fill-translate, fill-translate-anchor, fill-pattern // TODO: LAYOUT: fill-sort-key /** * Convert a MapLibre fill layer * @param fillLayer - the MapLibre fill layer * @returns the s2maps fill layer */ function convertLayerFill(fillLayer) { const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = fillLayer; return { name: id, type: 'fill', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, color: convertDataDrivenPropertyValueSpecification(paint['fill-color']), opacity: convertDataDrivenPropertyValueSpecification(paint['fill-opacity']), visible: layout.visibility !== 'none', }; } // TODO: line-miter-limit, line-round-limit, line-sort-key, line-translate, line-translate-anchor, line-pattern // TODO: line-blur, line-gradient, line-offset, line-gap-width, line-dasharray /** * Convert a MapLibre line layer * @param lineLayer - the MapLibre line layer * @returns the s2maps line layer */ function convertLayerLine(lineLayer) { const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = lineLayer; const color = convertDataDrivenPropertyValueSpecification(paint['line-color']); const dashColor = typeof color === 'string' ? color : 'rgba(0, 0, 0, 0)'; return { name: id, type: 'line', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, color, opacity: convertDataDrivenPropertyValueSpecification(paint['line-opacity']), width: convertDataDrivenPropertyValueSpecification(paint['line-width']), cap: convertPropertyValueSpecification(layout['line-cap']), join: convertDataDrivenPropertyValueSpecification(layout['line-join']), dasharray: convertDashArray(paint['line-dasharray'] ?? [], dashColor), visible: layout.visibility !== 'none', }; } // TODO: raster-hue-rotate, raster-brightness-min, raster-brightness-max /** * Convert a MapLibre raster layer * @param input - the MapLibre input layer * @returns the s2maps raster layer */ function convertLayerRaster(input) { const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = input; return { name: id, type: 'raster', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, opacity: convertPropertyValueSpecification(paint['raster-opacity']), saturation: convertPropertyValueSpecification(paint['raster-saturation']), contrast: convertPropertyValueSpecification(paint['raster-contrast']), visible: layout.visibility !== 'none', }; } // TODO: hillshade-illumination-anchor /** * Convert a MapLibre hillshade layer * @param input - the MapLibre input layer * @returns the s2maps hillshade layer */ function convertLayerHillshade(input) { if (input === undefined) throw new Error('Hillshade layer not supported'); const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = input; return { name: id, type: 'hillshade', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, azimuth: convertPropertyValueSpecification(paint['hillshade-illumination-direction']), // intensity: convertPropertyValueSpecification(paint['hillshade-exaggeration']), shadowColor: convertPropertyValueSpecification(paint['hillshade-shadow-color']), highlightColor: convertPropertyValueSpecification(paint['hillshade-highlight-color']), accentColor: convertPropertyValueSpecification(paint['hillshade-accent-color']), visible: layout.visibility !== 'none', }; } /** * Convert a MapLibre heatmap layer * @param input - the MapLibre input layer * @returns the s2maps heatmap layer */ function convertLayerHeatmap(input) { if (input === undefined) throw new Error('Heatmap layer not supported'); const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = input; return { name: id, type: 'heatmap', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, opacity: convertPropertyValueSpecification(paint['heatmap-opacity']), intensity: convertPropertyValueSpecification(paint['heatmap-intensity']), radius: convertDataDrivenPropertyValueSpecification(paint['heatmap-radius']), weight: convertDataDrivenPropertyValueSpecification(paint['heatmap-weight']), colorRamp: convertColorRamp(paint['heatmap-color']), visible: layout.visibility !== 'none', }; } // TODO: circle-sort-key, circle-blur, translate, translate-anchor // NOTE: circle-stroke-opacity? should I implement? /** * Convert a MapLibre circle layer * @param input - the MapLibre input layer * @returns the s2maps circle layer */ function convertLayerCircle(input) { const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = input; return { name: id, type: 'point', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, opacity: convertDataDrivenPropertyValueSpecification(paint['circle-opacity']), radius: convertDataDrivenPropertyValueSpecification(paint['circle-radius']), color: convertDataDrivenPropertyValueSpecification(paint['circle-color']), stroke: convertDataDrivenPropertyValueSpecification(paint['circle-stroke-color']), strokeWidth: convertDataDrivenPropertyValueSpecification(paint['circle-stroke-width']), visible: layout.visibility !== 'none', }; } // TODO: opacity, MANYYYYY different properties to convert /** * Convert a MapLibre symbol layer * @param input - the MapLibre input layer * @param glyphs - associated glyphs to inject into * @returns the s2maps symbol layer */ function convertLayerSymbol(input, glyphs) { const { id, source, filter, 'source-layer': layer, metadata, minzoom, maxzoom, layout = {}, paint = {}, } = input; // TODO: May be an ExpressionSpecification const fonts = layout['text-font']; let textFamily = ''; if (Array.isArray(fonts) && typeof fonts[0] === 'string') { // if we find regular, replace with robotoRegular & notoSansRegular if (fonts[0].toLocaleLowerCase().includes('regular')) { glyphs.robotoRegular = 'apiURL://glyphs-v2/RobotoRegular'; glyphs.NotoRegular = 'apiURL://glyphs-v2/NotoRegular'; textFamily = ['robotoRegular', 'NotoRegular']; } else { glyphs.robotoMedium = 'apiURL://glyphs-v2/RobotoMedium'; glyphs.NotoMedium = 'apiURL://glyphs-v2/NotoMedium'; textFamily = ['robotoMedium', 'NotoMedium']; } } else { glyphs.robotoMedium = 'apiURL://glyphs-v2/RobotoMedium'; textFamily = 'robotoMedium'; } return { name: id, type: 'glyph', filter: convertFilter(filter), source, layer, metadata, minzoom, maxzoom, // opacity: convertDataDrivenPropertyValueSpecification(paint['text-opacity']), textFill: convertDataDrivenPropertyValueSpecification(paint['text-color']), textStroke: convertDataDrivenPropertyValueSpecification(paint['text-halo-color']), textStrokeWidth: convertDataDrivenPropertyValueSpecification(paint['text-halo-width']), textSize: convertDataDrivenPropertyValueSpecification(layout['text-size']), textFamily, textField: convertDataDrivenPropertyValueSpecification(layout['text-field']), textAlign: convertDataDrivenPropertyValueSpecification(layout['text-justify']), textAnchor: convertDataDrivenPropertyValueSpecification(layout['text-anchor']), textOffset: convertDataDrivenPropertyValueSpecification(layout['text-offset']), // TODO: support PaddingSpecification textPadding: convertDataDrivenPropertyValueSpecification(layout['text-padding']), textWordWrap: convertDataDrivenPropertyValueSpecification(layout['text-max-width']), // TODO: properly convert textKerning: convertDataDrivenPropertyValueSpecification(layout['text-letter-spacing']), // TODO: properly convert textLineHeight: convertDataDrivenPropertyValueSpecification(layout['text-line-height']), // icon iconSize: convertDataDrivenPropertyValueSpecification(layout['icon-size']), iconFamily: convertDataDrivenPropertyValueSpecification(layout['icon-image']), iconField: convertDataDrivenPropertyValueSpecification(layout['icon-image']), iconAnchor: convertDataDrivenPropertyValueSpecification(layout['icon-anchor']), iconOffset: convertDataDrivenPropertyValueSpecification(layout['icon-offset']), // TODO: support PaddingSpecification iconPadding: convertDataDrivenPropertyValueSpecification(layout['icon-padding']), visible: layout.visibility !== 'none', geoFilter: ['line', 'poly'], }; } // TODO: /** * Convert a MapLibre color ramp * @param input - the MapLibre input color ramp * @returns the s2maps color ramp */ function convertColorRamp(input) { if (Array.isArray(input)) { return undefined; } else if (typeof input === 'object') { return undefined; } else { return undefined; } } /** * Convert a MapLibre filter * @param input - the MapLibre input filter * @returns the s2maps filter */ function convertFilter(input) { if (Array.isArray(input)) { if (input.length <= 1) return undefined; else { const [operator, expression] = input; if (operator === 'all' || operator === 'any' || operator === 'none') { if (typeof expression === 'boolean') return undefined; const [cmp, key, value] = expression; if (cmp === '!=' || cmp === 'has' || cmp === '!has' || cmp === 'in' || cmp === '!in' || cmp === '<' || cmp === '<=' || cmp === '==' || cmp === '>' || cmp === '>=') { return { comparator: cmp, key, value }; } else { return undefined; } } else { return undefined; } } } else { return undefined; } } // TODO: /** * Convert a MapLibre property * @param input - the MapLibre input property * @returns the s2maps property */ function convertPropertyValueSpecification(input) { if (input === undefined) return undefined; // if the input is not an object or array, it's a constant if (Array.isArray(input)) { if (input[0] === 'match') return convertPropertyValueSpecificationMatch(input); return undefined; } else if (typeof input === 'object') { return undefined; } else { return typeof input === 'string' ? replaceBrackets(input) : input; } } /** * Convert a MapLibre property * @param input - the MapLibre input property * @returns the s2maps property */ function convertPropertyValueSpecificationMatch(input) { const [, expression, ...rest] = input; const fallback = rest.pop(); if (!Array.isArray(expression) || expression[0] !== 'get') return undefined; const key = expression[1]; // we return a dataCondition const res = { dataCondition: { conditions: [], fallback: convertPropertyValueSpecification(fallback), }, }; for (let i = 0; i < rest.length; i += 2) { const value = rest[i]; const output = rest[i + 1]; if (value === undefined || output === undefined) return undefined; const isArray = Array.isArray(value); res.dataCondition?.conditions.push({ // @ts-expect-error - fix later filter: { key, comparator: isArray ? 'has' : '==', value }, input: convertPropertyValueSpecification(output), }); } return res; } // TODO: /** * Convert a MapLibre data-driven property * @param input - the MapLibre input property * @returns the s2maps property */ function convertDataDrivenPropertyValueSpecification(input) { if (Array.isArray(input)) { if (input[0] === 'match') return convertPropertyValueSpecificationMatch(input); return undefined; } else if (typeof input === 'object') { if ('stops' in input) { if (input.type === undefined || input.type === 'interval') { const input2 = input; return convertDataDrivenPropertyValueSpecificationStops(input2); } } return undefined; } else { // @ts-expect-error - fix later return replaceBrackets(input); } } // for now assume interval /** * Convert a MapLibre data-driven property * @param input - the MapLibre input property * @returns the s2maps property */ function convertDataDrivenPropertyValueSpecificationStops(input) { if ('property' in input) return undefined; if (typeof input.stops[0][0] === 'object') return undefined; const res = { inputRange: { type: 'zoom', ranges: [], }, }; for (const [zoom, value] of input.stops) { res.inputRange?.ranges.push({ stop: zoom, // @ts-expect-error - fix later input: replaceBrackets(value), }); } return res; } /** * create a function that takes a string as an input * if the input string has {word} (brackets around the word), replace it with "?word" * @param input - the input string * @returns the output string */ function replaceBrackets(input) { if (typeof input !== 'string') return input; return input.replace(/{(\w+)}/g, '?$1'); } /** * Convert a MapLibre dash array * @param inputDashes - the MapLibre input dash array * @param color - the color of the dash * @returns the s2maps dash array */ function convertDashArray(inputDashes, color) { const dashArray = []; if (Array.isArray(inputDashes) && inputDashes.length > 0 && typeof inputDashes[0] === 'number') { for (let i = 0; i < inputDashes.length; i++) { const dashSize = inputDashes[i] * 10; if (i % 2 === 1) dashArray.unshift([dashSize, color]); else dashArray.unshift([dashSize, 'rgba(255, 255, 255, 0)']); } } return dashArray; }