From 67c9b59433037a4797e7c7c3fd904c985b072b96 Mon Sep 17 00:00:00 2001 From: Michael Kreil Date: Wed, 22 Nov 2023 21:32:34 +0100 Subject: [PATCH] feat: implement style guesser --- src/lib/style_guesser.ts | 140 ++++++++++++++++++++++++++++++++ src/lib/utils.ts | 167 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/lib/style_guesser.ts diff --git a/src/lib/style_guesser.ts b/src/lib/style_guesser.ts new file mode 100644 index 0000000..db3d9ff --- /dev/null +++ b/src/lib/style_guesser.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/prefer-includes */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { BackgroundLayerSpecification, CircleLayerSpecification, FillLayerSpecification, LineLayerSpecification } from '@maplibre/maplibre-gl-style-spec'; +import { Colorful } from '../index.js'; +import type { MaplibreStyle, TileJSONSpecification, TileJSONSpecificationRaster, TileJSONSpecificationVector, VectorLayer } from './types.js'; +import { isTileJSONSpecification } from './types.js'; +import { RandomColor } from './utils.js'; + + + +export default function guess(spec: TileJSONSpecification): MaplibreStyle { + if (!isTileJSONSpecification(spec)) throw Error(); + spec.tilejson ??= '3.0.0'; + + switch (spec.type) { + case 'vector': + if (isShortbread(spec)) { + return getShortbreadStyle(spec); + } else { + return getInspectorStyle(spec); + } + case 'raster': + return getImageStyle(spec); + default: + throw Error('spec.type must be: "vector" or "raster"'); + } +} + +function isShortbread(spec: TileJSONSpecificationVector): boolean { + if (typeof spec !== 'object') return false; + if (!('vector_layers' in spec)) return false; + if (!Array.isArray(spec.vector_layers)) return false; + + + const layerIds = new Set(spec.vector_layers.map(l => String(l.id))); + const shortbreadIds = ['place_labels', 'boundaries', 'boundary_labels', 'addresses', 'water_lines', 'water_lines_labels', 'dam_lines', 'dam_polygons', 'pier_lines', 'pier_polygons', 'bridges', 'street_polygons', 'streets_polygons_labels', 'ferries', 'streets', 'street_labels', 'street_labels_points', 'aerialways', 'public_transport', 'buildings', 'water_polygons', 'ocean', 'water_polygons_labels', 'land', 'sites', 'pois']; + return shortbreadIds.every(id => layerIds.has(id)); +} + +function getShortbreadStyle(spec: TileJSONSpecificationVector): MaplibreStyle { + const builder = new Colorful(); + builder.hideLabels = true; + builder.tilesUrls = spec.tiles; + return builder.build(); +} + +function getInspectorStyle(spec: TileJSONSpecificationVector): MaplibreStyle { + const sourceName = 'vectorSource'; + + const layers: { + background: BackgroundLayerSpecification[]; + circle: CircleLayerSpecification[]; + line: LineLayerSpecification[]; + fill: FillLayerSpecification[]; + } = { background: [], circle: [], line: [], fill: [] }; + + layers.background.push({ 'id': 'background', 'type': 'background', 'paint': { 'background-color': '#fff' } }); + + const randomColor = new RandomColor(); + + spec.vector_layers.forEach((vector_layer: VectorLayer) => { + let luminosity = 'bright', saturation, hue; + + if (/water|ocean|lake|sea|river/.test(vector_layer.id)) hue = 'blue'; + if (/state|country|place/.test(vector_layer.id)) hue = 'pink'; + if (/road|highway|transport|streets/.test(vector_layer.id)) hue = 'orange'; + if (/contour|building/.test(vector_layer.id)) hue = 'monochrome'; + if (/building/.test(vector_layer.id)) luminosity = 'dark'; + if (/contour|landuse/.test(vector_layer.id)) hue = 'yellow'; + if (/wood|forest|park|landcover|land/.test(vector_layer.id)) hue = 'green'; + + if (/point/.test(vector_layer.id)) { + saturation = 'strong'; + luminosity = 'light'; + } + + const color = randomColor.randomColor({ + hue, + luminosity, + saturation, + seed: vector_layer.id, + opacity: 0.6, + }); + + layers.circle.push({ + id: `${sourceName}-${vector_layer.id}-circle`, + 'source-layer': vector_layer.id, + source: sourceName, + type: 'circle', + filter: ['==', '$type', 'Point'], + paint: { 'circle-color': color, 'circle-radius': 2 }, + }); + + layers.line.push({ + id: `${sourceName}-${vector_layer.id}-line`, + 'source-layer': vector_layer.id, + source: sourceName, + type: 'line', + filter: ['==', '$type', 'LineString'], + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': color }, + }); + + layers.fill.push({ + id: `${sourceName}-${vector_layer.id}-fill`, + 'source-layer': vector_layer.id, + source: sourceName, + type: 'fill', + filter: ['==', '$type', 'Polygon'], + paint: { 'fill-color': color, 'fill-opacity': 0.3, 'fill-antialias': true, 'fill-outline-color': color }, + }); + }); + + return { + version: 8, + sources: { + [sourceName]: spec, + }, + layers: [ + ...layers.background, + ...layers.fill, + ...layers.line, + ...layers.circle, + ], + }; +} + +function getImageStyle(spec: TileJSONSpecificationRaster): MaplibreStyle { + const sourceName = 'rasterSource'; + return { + version: 8, + sources: { [sourceName]: spec }, + layers: [{ + id: 'raster', + type: 'raster', + source: sourceName, + }], + }; +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5e6a375..c2c010d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -114,3 +114,170 @@ export function deepMerge(source0: T, ...sources: T[]): T { } return target; } + +export interface RandomColorOptions { + seed?: string; + hue?: number | string; + opacity?: number; + luminosity?: number | string; + saturation?: number | string; +} + +interface ColorInfo { + hueRange: [number, number] | null; + lowerBounds: [number, number][]; + saturationRange: [number, number]; + brightnessRange: [number, number]; +} + +export class RandomColor { + #seed: number; + + #colorDictionary: Record; + + public constructor() { + this.#seed = 0; + this.#colorDictionary = {}; + this.#initColorDictionary(); + } + + + public randomColor(options: RandomColorOptions = { hue: 0 }): string { + + if (options.seed != null) { + this.#seed = this.#stringToInteger(options.seed); + } + + const H = this.#pickHue(options); + const S = this.#pickSaturation(H, options); + const B = this.#pickBrightness(H, S, options); + const hsl = this.#HSVtoHSL([H, S, B]).map(v => v.toFixed(0)); + return `hsla(${hsl[0]},${hsl[1]}%,${hsl[2]}%,${options.opacity ?? 1})`; + } + + #pickHue(options: RandomColorOptions): number { + let hue = this.#randomWithin(this.#getHueRange(options.hue)); + if (hue < 0) hue = 360 + hue; + return hue; + } + + #pickSaturation(hue: number, options: RandomColorOptions): number { + if (options.hue === 'monochrome') return 0; + if (options.luminosity === 'random') return this.#randomWithin([0, 100]); + + const { saturationRange } = this.#getColorInfo(hue); + let [sMin, sMax] = saturationRange; + + if (options.saturation === 'strong') return sMax; + + switch (options.luminosity) { + case 'bright': sMin = 55; break; + case 'dark': sMin = sMax - 10; break; + case 'light': sMax = 55; break; + default: + } + + return this.#randomWithin([sMin, sMax]); + } + + #pickBrightness(h: number, s: number, options: RandomColorOptions): number { + let bMin = this.#getMinimumBrightness(h, s), bMax = 100; + + switch (options.luminosity) { + case 'dark': bMax = Math.min(100, bMin + 20); break; + case 'light': bMin = (bMax + bMin) / 2; break; + case 'random': bMin = 0; bMax = 100; break; + default: + } + + return this.#randomWithin([bMin, bMax]); + } + + #getMinimumBrightness(h: number, s: number): number { + const { lowerBounds } = this.#getColorInfo(h); + + for (let i = 0; i < lowerBounds.length - 1; i++) { + const [s1, v1] = lowerBounds[i]; + const [s2, v2] = lowerBounds[i + 1]; + if (s >= s1 && s <= s2) { + const m = (v2 - v1) / (s2 - s1), b = v1 - m * s1; + return m * s + b; + } + } + + return 0; + } + + #getHueRange(hue?: number | string): [number, number] { + if (typeof hue === 'number') { + if (hue < 360 && hue > 0) return [hue, hue]; + } + + if (typeof hue === 'string') { + const color = this.#colorDictionary[hue]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (color?.hueRange) return color.hueRange; + } + + return [0, 360]; + } + + #getColorInfo(hue: number): ColorInfo { + // Maps red colors to make picking hue easier + if (hue >= 334 && hue <= 360) hue -= 360; + + for (const colorName in this.#colorDictionary) { + const color = this.#colorDictionary[colorName]; + if (color.hueRange && hue >= color.hueRange[0] && hue <= color.hueRange[1]) { + return this.#colorDictionary[colorName]; + } + } + throw Error('Color not found'); + } + + #randomWithin(range: [number, number]): number { + //Seeded random algorithm from http://indiegamr.com/generate-repeatable-random-numbers-in-js/ + const max = range[1] || 1; + const min = range[0] || 0; + this.#seed = (this.#seed * 9301 + 49297) % 233280; + const rnd = this.#seed / 233280.0; + return Math.floor(min + rnd * (max - min)); + } + + #initColorDictionary(): void { + this.#colorDictionary = {}; + + const defineColor = (name: string, hueRange: [number, number] | null, lowerBounds: [number, number][]): void => { + const [greyest] = lowerBounds; + const colorful = lowerBounds[lowerBounds.length - 1]; + + this.#colorDictionary[name] = { + hueRange, + lowerBounds, + saturationRange: [greyest[0], colorful[0]], + brightnessRange: [colorful[1], greyest[1]], + }; + }; + + defineColor('monochrome', null, [[0, 0], [100, 0]]); + defineColor('red', [-26, 18], [[20, 100], [30, 92], [40, 89], [50, 85], [60, 78], [70, 70], [80, 60], [90, 55], [100, 50]]); + defineColor('orange', [18, 46], [[20, 100], [30, 93], [40, 88], [50, 86], [60, 85], [70, 70], [100, 70]]); + defineColor('yellow', [46, 62], [[25, 100], [40, 94], [50, 89], [60, 86], [70, 84], [80, 82], [90, 80], [100, 75]]); + defineColor('green', [62, 178], [[30, 100], [40, 90], [50, 85], [60, 81], [70, 74], [80, 64], [90, 50], [100, 40]]); + defineColor('blue', [178, 257], [[20, 100], [30, 86], [40, 80], [50, 74], [60, 60], [70, 52], [80, 44], [90, 39], [100, 35]]); + defineColor('purple', [257, 282], [[20, 100], [30, 87], [40, 79], [50, 70], [60, 65], [70, 59], [80, 52], [90, 45], [100, 42]]); + defineColor('pink', [282, 334], [[20, 100], [30, 90], [40, 86], [60, 84], [80, 80], [90, 75], [100, 73]]); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + #HSVtoHSL(hsv: [number, number, number]): [number, number, number] { + const s = hsv[1] / 100, v = hsv[2] / 100, k = (2 - s) * v; + return [hsv[0], 100 * s * v / (k < 1 ? k : 2 - k), 100 * k / 2]; + } + + #stringToInteger(s: string): number { + let i = 0; + for (let p = 0; p < s.length; p++) i = (i * 0x101 + s.charCodeAt(p)) % 0x100000000; + return i; + } +}