Initial commit

This commit is contained in:
2026-04-15 17:08:39 +02:00
parent ae164c47a8
commit 47fd1c2b7a
1819 changed files with 685388 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
Copyright (c) 2020, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,94 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/7ff2cda8-f564-4e70-a971-d34152f969f0#gh-light-mode-only" alt="MapLibre Logo" width="200">
<img src="https://github.com/user-attachments/assets/cee8376b-9812-40ff-91c6-2d53f9581b83#gh-dark-mode-only" alt="MapLibre Logo" width="200">
</p>
# MapLibre Style Specification & Utilities
[![NPM Version](https://badge.fury.io/js/@maplibre%2Fmaplibre-gl-style-spec.svg)](https://npmjs.org/package/@maplibre/maplibre-gl-style-spec)
[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg?style=flat)](LICENSE.txt) [![PRs](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](https://opensource.org/licenses/BSD-3-Clause) [![codecov](https://codecov.io/gh/maplibre/maplibre-style-spec/branch/main/graph/badge.svg)](https://codecov.io/gh/maplibre/maplibre-style-spec)
This repository contains code and reference files that define the MapLibre style specification and provides some utilities for working with MapLibre styles.
The style specification is used in MapLibre GL JS and in MapLibre Native. Our long-term goal is to have feature parity between the web and the native libraries.
## Contributing
If you want to contribute to the style specification, please open an issue with a design proposal. Once your design proposal has been accepted, you can open a pull request and implement your changes.
We aim to avoid breaking changes in the MapLibre style specification because it makes life easier for our users.
## Documentation
The [documentation](https://maplibre.org/maplibre-style-spec) of the style specification also lives in this repository.
We use [Zensical](https://www.zensical.org/).
To work on the documentation locally, you need to have Docker installed and running.
Start Zensical with
```bash
npm run start-docs
```
Most of the documentation is generated (from e.g. `v8.json`).
In another terminal, run:
```bash
WATCH=1 npm run generate-docs
```
This will re-run the generation script when needed.
Note that generated files should not be checked in, and they are excluded in `.gitignore`.
Make sure to keep this file up to date and ignore generated files while making sure static Markdown files are not ignored.
## NPM Package
The MapLibre style specification and utilities are published as a separate npm
package so that they can be installed without the bulk of GL JS.
```bash
npm install @maplibre/maplibre-gl-style-spec
```
## CLI Tools
If you install this package globally, you will have access to several CLI tools.
```bash
npm install @maplibre/maplibre-gl-style-spec --global
```
### `gl-style-migrate`
This repo contains scripts for migrating GL styles of any version to the latest version (currently v8).
You can migrate a style like this:
```bash
$ gl-style-migrate bright-v7.json > bright-v8.json
```
To migrate a file in place, you can use the `sponge` utility from the `moreutils` package:
```bash
$ brew install moreutils
$ gl-style-migrate bright.json | sponge bright.json
```
### `gl-style-format`
```bash
$ gl-style-format style.json
```
Will format the given style JSON to use standard indentation and sorted object keys.
### `gl-style-validate`
```bash
$ gl-style-validate style.json
```
Will validate the given style JSON and print errors to stdout.
Provide a `--json` flag to get JSON output.

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import fs from 'fs';
import minimist from 'minimist';
import {format} from '../src/format';
const argv = minimist(process.argv.slice(2));
if (argv.help || argv.h || (!argv._.length && process.stdin.isTTY)) {
help();
} else {
console.log(format(JSON.parse(fs.readFileSync(argv._[0]).toString()), argv.space));
}
function help() {
console.log('usage:');
console.log(' gl-style-format source.json > destination.json');
console.log('');
console.log('options:');
console.log(' --space <num>');
console.log(' Number of spaces in output (default "2")');
console.log(' Pass "0" for minified output.');
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env node
import fs from 'fs';
import minimist from 'minimist';
import {format} from '../src/format';
import {migrate} from '../src/migrate';
const argv = minimist(process.argv.slice(2));
if (argv.help || argv.h || (!argv._.length && process.stdin.isTTY)) {
help();
} else {
console.log(format(migrate(JSON.parse(fs.readFileSync(argv._[0]).toString()))));
}
function help() {
console.log('usage:');
console.log(' gl-style-migrate style-v7.json > style-v8.json');
}

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
import minimist from 'minimist';
import rw from 'rw';
import {validateStyle as validate} from '../src/validate_style';
const argv = minimist(process.argv.slice(2), {
boolean: 'json'
});
if (argv.help || argv.h || (!argv._.length && process.stdin.isTTY)) {
help();
} else {
let status = 0;
if (!argv._.length) {
argv._.push('/dev/stdin');
}
argv._.forEach((file) => {
const errors = validate(rw.readFileSync(file, 'utf8'));
if (errors.length) {
if (argv.json) {
process.stdout.write(JSON.stringify(errors, null, 2));
} else {
errors.forEach((e) => {
console.log('%s:%d: %s', file, e.line, e.message);
});
}
status = 1;
}
});
process.exit(status);
}
function help() {
console.log('usage:');
console.log(' gl-style-validate file.json');
console.log(' gl-style-validate < file.json');
console.log('');
console.log('options:');
console.log('--json output errors as json');
}

View File

@@ -0,0 +1,591 @@
#!/usr/bin/env node
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('fs')) :
typeof define === 'function' && define.amd ? define(['fs'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.fs));
})(this, (function (fs) { 'use strict';
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var minimist$1;
var hasRequiredMinimist;
function requireMinimist () {
if (hasRequiredMinimist) return minimist$1;
hasRequiredMinimist = 1;
function hasKey(obj, keys) {
var o = obj;
keys.slice(0, -1).forEach(function (key) {
o = o[key] || {};
});
var key = keys[keys.length - 1];
return key in o;
}
function isNumber(x) {
if (typeof x === 'number') { return true; }
if ((/^0x[0-9a-f]+$/i).test(x)) { return true; }
return (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$/).test(x);
}
function isConstructorOrProto(obj, key) {
return (key === 'constructor' && typeof obj[key] === 'function') || key === '__proto__';
}
minimist$1 = function (args, opts) {
if (!opts) { opts = {}; }
var flags = {
bools: {},
strings: {},
unknownFn: null,
};
if (typeof opts.unknown === 'function') {
flags.unknownFn = opts.unknown;
}
if (typeof opts.boolean === 'boolean' && opts.boolean) {
flags.allBools = true;
} else {
[].concat(opts.boolean).filter(Boolean).forEach(function (key) {
flags.bools[key] = true;
});
}
var aliases = {};
function aliasIsBoolean(key) {
return aliases[key].some(function (x) {
return flags.bools[x];
});
}
Object.keys(opts.alias || {}).forEach(function (key) {
aliases[key] = [].concat(opts.alias[key]);
aliases[key].forEach(function (x) {
aliases[x] = [key].concat(aliases[key].filter(function (y) {
return x !== y;
}));
});
});
[].concat(opts.string).filter(Boolean).forEach(function (key) {
flags.strings[key] = true;
if (aliases[key]) {
[].concat(aliases[key]).forEach(function (k) {
flags.strings[k] = true;
});
}
});
var defaults = opts.default || {};
var argv = { _: [] };
function argDefined(key, arg) {
return (flags.allBools && (/^--[^=]+$/).test(arg))
|| flags.strings[key]
|| flags.bools[key]
|| aliases[key];
}
function setKey(obj, keys, value) {
var o = obj;
for (var i = 0; i < keys.length - 1; i++) {
var key = keys[i];
if (isConstructorOrProto(o, key)) { return; }
if (o[key] === undefined) { o[key] = {}; }
if (
o[key] === Object.prototype
|| o[key] === Number.prototype
|| o[key] === String.prototype
) {
o[key] = {};
}
if (o[key] === Array.prototype) { o[key] = []; }
o = o[key];
}
var lastKey = keys[keys.length - 1];
if (isConstructorOrProto(o, lastKey)) { return; }
if (
o === Object.prototype
|| o === Number.prototype
|| o === String.prototype
) {
o = {};
}
if (o === Array.prototype) { o = []; }
if (o[lastKey] === undefined || flags.bools[lastKey] || typeof o[lastKey] === 'boolean') {
o[lastKey] = value;
} else if (Array.isArray(o[lastKey])) {
o[lastKey].push(value);
} else {
o[lastKey] = [o[lastKey], value];
}
}
function setArg(key, val, arg) {
if (arg && flags.unknownFn && !argDefined(key, arg)) {
if (flags.unknownFn(arg) === false) { return; }
}
var value = !flags.strings[key] && isNumber(val)
? Number(val)
: val;
setKey(argv, key.split('.'), value);
(aliases[key] || []).forEach(function (x) {
setKey(argv, x.split('.'), value);
});
}
Object.keys(flags.bools).forEach(function (key) {
setArg(key, defaults[key] === undefined ? false : defaults[key]);
});
var notFlags = [];
if (args.indexOf('--') !== -1) {
notFlags = args.slice(args.indexOf('--') + 1);
args = args.slice(0, args.indexOf('--'));
}
for (var i = 0; i < args.length; i++) {
var arg = args[i];
var key;
var next;
if ((/^--.+=/).test(arg)) {
// Using [\s\S] instead of . because js doesn't support the
// 'dotall' regex modifier. See:
// http://stackoverflow.com/a/1068308/13216
var m = arg.match(/^--([^=]+)=([\s\S]*)$/);
key = m[1];
var value = m[2];
if (flags.bools[key]) {
value = value !== 'false';
}
setArg(key, value, arg);
} else if ((/^--no-.+/).test(arg)) {
key = arg.match(/^--no-(.+)/)[1];
setArg(key, false, arg);
} else if ((/^--.+/).test(arg)) {
key = arg.match(/^--(.+)/)[1];
next = args[i + 1];
if (
next !== undefined
&& !(/^(-|--)[^-]/).test(next)
&& !flags.bools[key]
&& !flags.allBools
&& (aliases[key] ? !aliasIsBoolean(key) : true)
) {
setArg(key, next, arg);
i += 1;
} else if ((/^(true|false)$/).test(next)) {
setArg(key, next === 'true', arg);
i += 1;
} else {
setArg(key, flags.strings[key] ? '' : true, arg);
}
} else if ((/^-[^-]+/).test(arg)) {
var letters = arg.slice(1, -1).split('');
var broken = false;
for (var j = 0; j < letters.length; j++) {
next = arg.slice(j + 2);
if (next === '-') {
setArg(letters[j], next, arg);
continue;
}
if ((/[A-Za-z]/).test(letters[j]) && next[0] === '=') {
setArg(letters[j], next.slice(1), arg);
broken = true;
break;
}
if (
(/[A-Za-z]/).test(letters[j])
&& (/-?\d+(\.\d*)?(e-?\d+)?$/).test(next)
) {
setArg(letters[j], next, arg);
broken = true;
break;
}
if (letters[j + 1] && letters[j + 1].match(/\W/)) {
setArg(letters[j], arg.slice(j + 2), arg);
broken = true;
break;
} else {
setArg(letters[j], flags.strings[letters[j]] ? '' : true, arg);
}
}
key = arg.slice(-1)[0];
if (!broken && key !== '-') {
if (
args[i + 1]
&& !(/^(-|--)[^-]/).test(args[i + 1])
&& !flags.bools[key]
&& (aliases[key] ? !aliasIsBoolean(key) : true)
) {
setArg(key, args[i + 1], arg);
i += 1;
} else if (args[i + 1] && (/^(true|false)$/).test(args[i + 1])) {
setArg(key, args[i + 1] === 'true', arg);
i += 1;
} else {
setArg(key, flags.strings[key] ? '' : true, arg);
}
}
} else {
if (!flags.unknownFn || flags.unknownFn(arg) !== false) {
argv._.push(flags.strings._ || !isNumber(arg) ? arg : Number(arg));
}
if (opts.stopEarly) {
argv._.push.apply(argv._, args.slice(i + 1));
break;
}
}
}
Object.keys(defaults).forEach(function (k) {
if (!hasKey(argv, k.split('.'))) {
setKey(argv, k.split('.'), defaults[k]);
(aliases[k] || []).forEach(function (x) {
setKey(argv, x.split('.'), defaults[k]);
});
}
});
if (opts['--']) {
argv['--'] = notFlags.slice();
} else {
notFlags.forEach(function (k) {
argv._.push(k);
});
}
return argv;
};
return minimist$1;
}
var minimistExports = requireMinimist();
var minimist = /*@__PURE__*/getDefaultExportFromCjs(minimistExports);
var $root = {
version: {
required: true,
type: "enum",
values: [
8
]
},
name: {
type: "string"
},
metadata: {
type: "*"
},
center: {
type: "array",
value: "number",
length: 2
},
centerAltitude: {
type: "number"
},
zoom: {
type: "number"
},
bearing: {
type: "number",
"default": 0,
period: 360,
units: "degrees"
},
pitch: {
type: "number",
"default": 0,
units: "degrees"
},
roll: {
type: "number",
"default": 0,
units: "degrees"
},
state: {
type: "state",
"default": {
}
},
light: {
type: "light"
},
sky: {
type: "sky"
},
projection: {
type: "projection"
},
terrain: {
type: "terrain"
},
sources: {
required: true,
type: "sources"
},
sprite: {
type: "sprite"
},
glyphs: {
type: "string"
},
"font-faces": {
type: "fontFaces"
},
transition: {
type: "transition"
},
layers: {
required: true,
type: "array",
value: "layer"
}
};
var layer = {
id: {
type: "string",
required: true
},
type: {
type: "enum",
values: {
fill: {
},
line: {
},
symbol: {
},
circle: {
},
heatmap: {
},
"fill-extrusion": {
},
raster: {
},
hillshade: {
},
"color-relief": {
},
background: {
}
},
required: true
},
metadata: {
type: "*"
},
source: {
type: "string"
},
"source-layer": {
type: "string"
},
minzoom: {
type: "number",
minimum: 0,
maximum: 24
},
maxzoom: {
type: "number",
minimum: 0,
maximum: 24
},
filter: {
type: "filter"
},
layout: {
type: "layout"
},
paint: {
type: "paint"
}
};
var latest = {
$root: $root,
layer: layer};
// Note: This regex matches even invalid JSON strings, but since were
// working on the output of `JSON.stringify` we know that only valid strings
// are present (unless the user supplied a weird `options.indent` but in
// that case we dont care since the output would be invalid anyway).
const stringOrChar = /("(?:[^\\"]|\\.)*")|[:,]/g;
function stringify(passedObj, options = {}) {
const indent = JSON.stringify(
[1],
undefined,
options.indent === undefined ? 2 : options.indent
).slice(2, -3);
const maxLength =
indent === ""
? Infinity
: options.maxLength === undefined
? 80
: options.maxLength;
let { replacer } = options;
return (function _stringify(obj, currentIndent, reserved) {
if (obj && typeof obj.toJSON === "function") {
obj = obj.toJSON();
}
const string = JSON.stringify(obj, replacer);
if (string === undefined) {
return string;
}
const length = maxLength - currentIndent.length - reserved;
if (string.length <= length) {
const prettified = string.replace(
stringOrChar,
(match, stringLiteral) => {
return stringLiteral || `${match} `;
}
);
if (prettified.length <= length) {
return prettified;
}
}
if (replacer != null) {
obj = JSON.parse(string);
replacer = undefined;
}
if (typeof obj === "object" && obj !== null) {
const nextIndent = currentIndent + indent;
const items = [];
let index = 0;
let start;
let end;
if (Array.isArray(obj)) {
start = "[";
end = "]";
const { length } = obj;
for (; index < length; index++) {
items.push(
_stringify(obj[index], nextIndent, index === length - 1 ? 0 : 1) ||
"null"
);
}
} else {
start = "{";
end = "}";
const keys = Object.keys(obj);
const { length } = keys;
for (; index < length; index++) {
const key = keys[index];
const keyPart = `${JSON.stringify(key)}: `;
const value = _stringify(
obj[key],
nextIndent,
keyPart.length + (index === length - 1 ? 0 : 1)
);
if (value !== undefined) {
items.push(keyPart + value);
}
}
}
if (items.length > 0) {
return [start, indent + items.join(`,\n${nextIndent}`), end].join(
`\n${currentIndent}`
);
}
}
return string;
})(passedObj, "", 0);
}
function sortKeysBy(obj, reference) {
const result = {};
for (const key in reference) {
if (obj[key] !== undefined) {
result[key] = obj[key];
}
}
for (const key in obj) {
if (result[key] === undefined) {
result[key] = obj[key];
}
}
return result;
}
/**
* Format a MapLibre Style. Returns a stringified style with its keys
* sorted in the same order as the reference style.
*
* The optional `space` argument is passed to
* [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
* to generate formatted output.
*
* If `space` is unspecified, a default of `2` spaces will be used.
*
* @private
* @param {Object} style a MapLibre Style
* @param {number} [space] space argument to pass to `JSON.stringify`
* @returns {string} stringified formatted JSON
* @example
* var fs = require('fs');
* var format = require('maplibre-gl-style-spec').format;
* var style = fs.readFileSync('./source.json', 'utf8');
* fs.writeFileSync('./dest.json', format(style));
* fs.writeFileSync('./dest.min.json', format(style, 0));
*/
function format(style, space = 2) {
style = sortKeysBy(style, latest.$root);
if (style.layers) {
style.layers = style.layers.map((layer) => sortKeysBy(layer, latest.layer));
}
return stringify(style, { indent: space });
}
const argv = minimist(process.argv.slice(2));
if (argv.help || argv.h || (!argv._.length && process.stdin.isTTY)) {
help();
} else {
console.log(format(JSON.parse(fs.readFileSync(argv._[0]).toString()), argv.space));
}
function help() {
console.log('usage:');
console.log(' gl-style-format source.json > destination.json');
console.log('');
console.log('options:');
console.log(' --space <num>');
console.log(' Number of spaces in output (default "2")');
console.log(' Pass "0" for minified output.');
}
}));
//# sourceMappingURL=gl-style-format.cjs.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,585 @@
#!/usr/bin/env node
import fs from 'fs';
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var minimist$1;
var hasRequiredMinimist;
function requireMinimist () {
if (hasRequiredMinimist) return minimist$1;
hasRequiredMinimist = 1;
function hasKey(obj, keys) {
var o = obj;
keys.slice(0, -1).forEach(function (key) {
o = o[key] || {};
});
var key = keys[keys.length - 1];
return key in o;
}
function isNumber(x) {
if (typeof x === 'number') { return true; }
if ((/^0x[0-9a-f]+$/i).test(x)) { return true; }
return (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(e[-+]?\d+)?$/).test(x);
}
function isConstructorOrProto(obj, key) {
return (key === 'constructor' && typeof obj[key] === 'function') || key === '__proto__';
}
minimist$1 = function (args, opts) {
if (!opts) { opts = {}; }
var flags = {
bools: {},
strings: {},
unknownFn: null,
};
if (typeof opts.unknown === 'function') {
flags.unknownFn = opts.unknown;
}
if (typeof opts.boolean === 'boolean' && opts.boolean) {
flags.allBools = true;
} else {
[].concat(opts.boolean).filter(Boolean).forEach(function (key) {
flags.bools[key] = true;
});
}
var aliases = {};
function aliasIsBoolean(key) {
return aliases[key].some(function (x) {
return flags.bools[x];
});
}
Object.keys(opts.alias || {}).forEach(function (key) {
aliases[key] = [].concat(opts.alias[key]);
aliases[key].forEach(function (x) {
aliases[x] = [key].concat(aliases[key].filter(function (y) {
return x !== y;
}));
});
});
[].concat(opts.string).filter(Boolean).forEach(function (key) {
flags.strings[key] = true;
if (aliases[key]) {
[].concat(aliases[key]).forEach(function (k) {
flags.strings[k] = true;
});
}
});
var defaults = opts.default || {};
var argv = { _: [] };
function argDefined(key, arg) {
return (flags.allBools && (/^--[^=]+$/).test(arg))
|| flags.strings[key]
|| flags.bools[key]
|| aliases[key];
}
function setKey(obj, keys, value) {
var o = obj;
for (var i = 0; i < keys.length - 1; i++) {
var key = keys[i];
if (isConstructorOrProto(o, key)) { return; }
if (o[key] === undefined) { o[key] = {}; }
if (
o[key] === Object.prototype
|| o[key] === Number.prototype
|| o[key] === String.prototype
) {
o[key] = {};
}
if (o[key] === Array.prototype) { o[key] = []; }
o = o[key];
}
var lastKey = keys[keys.length - 1];
if (isConstructorOrProto(o, lastKey)) { return; }
if (
o === Object.prototype
|| o === Number.prototype
|| o === String.prototype
) {
o = {};
}
if (o === Array.prototype) { o = []; }
if (o[lastKey] === undefined || flags.bools[lastKey] || typeof o[lastKey] === 'boolean') {
o[lastKey] = value;
} else if (Array.isArray(o[lastKey])) {
o[lastKey].push(value);
} else {
o[lastKey] = [o[lastKey], value];
}
}
function setArg(key, val, arg) {
if (arg && flags.unknownFn && !argDefined(key, arg)) {
if (flags.unknownFn(arg) === false) { return; }
}
var value = !flags.strings[key] && isNumber(val)
? Number(val)
: val;
setKey(argv, key.split('.'), value);
(aliases[key] || []).forEach(function (x) {
setKey(argv, x.split('.'), value);
});
}
Object.keys(flags.bools).forEach(function (key) {
setArg(key, defaults[key] === undefined ? false : defaults[key]);
});
var notFlags = [];
if (args.indexOf('--') !== -1) {
notFlags = args.slice(args.indexOf('--') + 1);
args = args.slice(0, args.indexOf('--'));
}
for (var i = 0; i < args.length; i++) {
var arg = args[i];
var key;
var next;
if ((/^--.+=/).test(arg)) {
// Using [\s\S] instead of . because js doesn't support the
// 'dotall' regex modifier. See:
// http://stackoverflow.com/a/1068308/13216
var m = arg.match(/^--([^=]+)=([\s\S]*)$/);
key = m[1];
var value = m[2];
if (flags.bools[key]) {
value = value !== 'false';
}
setArg(key, value, arg);
} else if ((/^--no-.+/).test(arg)) {
key = arg.match(/^--no-(.+)/)[1];
setArg(key, false, arg);
} else if ((/^--.+/).test(arg)) {
key = arg.match(/^--(.+)/)[1];
next = args[i + 1];
if (
next !== undefined
&& !(/^(-|--)[^-]/).test(next)
&& !flags.bools[key]
&& !flags.allBools
&& (aliases[key] ? !aliasIsBoolean(key) : true)
) {
setArg(key, next, arg);
i += 1;
} else if ((/^(true|false)$/).test(next)) {
setArg(key, next === 'true', arg);
i += 1;
} else {
setArg(key, flags.strings[key] ? '' : true, arg);
}
} else if ((/^-[^-]+/).test(arg)) {
var letters = arg.slice(1, -1).split('');
var broken = false;
for (var j = 0; j < letters.length; j++) {
next = arg.slice(j + 2);
if (next === '-') {
setArg(letters[j], next, arg);
continue;
}
if ((/[A-Za-z]/).test(letters[j]) && next[0] === '=') {
setArg(letters[j], next.slice(1), arg);
broken = true;
break;
}
if (
(/[A-Za-z]/).test(letters[j])
&& (/-?\d+(\.\d*)?(e-?\d+)?$/).test(next)
) {
setArg(letters[j], next, arg);
broken = true;
break;
}
if (letters[j + 1] && letters[j + 1].match(/\W/)) {
setArg(letters[j], arg.slice(j + 2), arg);
broken = true;
break;
} else {
setArg(letters[j], flags.strings[letters[j]] ? '' : true, arg);
}
}
key = arg.slice(-1)[0];
if (!broken && key !== '-') {
if (
args[i + 1]
&& !(/^(-|--)[^-]/).test(args[i + 1])
&& !flags.bools[key]
&& (aliases[key] ? !aliasIsBoolean(key) : true)
) {
setArg(key, args[i + 1], arg);
i += 1;
} else if (args[i + 1] && (/^(true|false)$/).test(args[i + 1])) {
setArg(key, args[i + 1] === 'true', arg);
i += 1;
} else {
setArg(key, flags.strings[key] ? '' : true, arg);
}
}
} else {
if (!flags.unknownFn || flags.unknownFn(arg) !== false) {
argv._.push(flags.strings._ || !isNumber(arg) ? arg : Number(arg));
}
if (opts.stopEarly) {
argv._.push.apply(argv._, args.slice(i + 1));
break;
}
}
}
Object.keys(defaults).forEach(function (k) {
if (!hasKey(argv, k.split('.'))) {
setKey(argv, k.split('.'), defaults[k]);
(aliases[k] || []).forEach(function (x) {
setKey(argv, x.split('.'), defaults[k]);
});
}
});
if (opts['--']) {
argv['--'] = notFlags.slice();
} else {
notFlags.forEach(function (k) {
argv._.push(k);
});
}
return argv;
};
return minimist$1;
}
var minimistExports = requireMinimist();
var minimist = /*@__PURE__*/getDefaultExportFromCjs(minimistExports);
var $root = {
version: {
required: true,
type: "enum",
values: [
8
]
},
name: {
type: "string"
},
metadata: {
type: "*"
},
center: {
type: "array",
value: "number",
length: 2
},
centerAltitude: {
type: "number"
},
zoom: {
type: "number"
},
bearing: {
type: "number",
"default": 0,
period: 360,
units: "degrees"
},
pitch: {
type: "number",
"default": 0,
units: "degrees"
},
roll: {
type: "number",
"default": 0,
units: "degrees"
},
state: {
type: "state",
"default": {
}
},
light: {
type: "light"
},
sky: {
type: "sky"
},
projection: {
type: "projection"
},
terrain: {
type: "terrain"
},
sources: {
required: true,
type: "sources"
},
sprite: {
type: "sprite"
},
glyphs: {
type: "string"
},
"font-faces": {
type: "fontFaces"
},
transition: {
type: "transition"
},
layers: {
required: true,
type: "array",
value: "layer"
}
};
var layer = {
id: {
type: "string",
required: true
},
type: {
type: "enum",
values: {
fill: {
},
line: {
},
symbol: {
},
circle: {
},
heatmap: {
},
"fill-extrusion": {
},
raster: {
},
hillshade: {
},
"color-relief": {
},
background: {
}
},
required: true
},
metadata: {
type: "*"
},
source: {
type: "string"
},
"source-layer": {
type: "string"
},
minzoom: {
type: "number",
minimum: 0,
maximum: 24
},
maxzoom: {
type: "number",
minimum: 0,
maximum: 24
},
filter: {
type: "filter"
},
layout: {
type: "layout"
},
paint: {
type: "paint"
}
};
var latest = {
$root: $root,
layer: layer};
// Note: This regex matches even invalid JSON strings, but since were
// working on the output of `JSON.stringify` we know that only valid strings
// are present (unless the user supplied a weird `options.indent` but in
// that case we dont care since the output would be invalid anyway).
const stringOrChar = /("(?:[^\\"]|\\.)*")|[:,]/g;
function stringify(passedObj, options = {}) {
const indent = JSON.stringify(
[1],
undefined,
options.indent === undefined ? 2 : options.indent
).slice(2, -3);
const maxLength =
indent === ""
? Infinity
: options.maxLength === undefined
? 80
: options.maxLength;
let { replacer } = options;
return (function _stringify(obj, currentIndent, reserved) {
if (obj && typeof obj.toJSON === "function") {
obj = obj.toJSON();
}
const string = JSON.stringify(obj, replacer);
if (string === undefined) {
return string;
}
const length = maxLength - currentIndent.length - reserved;
if (string.length <= length) {
const prettified = string.replace(
stringOrChar,
(match, stringLiteral) => {
return stringLiteral || `${match} `;
}
);
if (prettified.length <= length) {
return prettified;
}
}
if (replacer != null) {
obj = JSON.parse(string);
replacer = undefined;
}
if (typeof obj === "object" && obj !== null) {
const nextIndent = currentIndent + indent;
const items = [];
let index = 0;
let start;
let end;
if (Array.isArray(obj)) {
start = "[";
end = "]";
const { length } = obj;
for (; index < length; index++) {
items.push(
_stringify(obj[index], nextIndent, index === length - 1 ? 0 : 1) ||
"null"
);
}
} else {
start = "{";
end = "}";
const keys = Object.keys(obj);
const { length } = keys;
for (; index < length; index++) {
const key = keys[index];
const keyPart = `${JSON.stringify(key)}: `;
const value = _stringify(
obj[key],
nextIndent,
keyPart.length + (index === length - 1 ? 0 : 1)
);
if (value !== undefined) {
items.push(keyPart + value);
}
}
}
if (items.length > 0) {
return [start, indent + items.join(`,\n${nextIndent}`), end].join(
`\n${currentIndent}`
);
}
}
return string;
})(passedObj, "", 0);
}
function sortKeysBy(obj, reference) {
const result = {};
for (const key in reference) {
if (obj[key] !== undefined) {
result[key] = obj[key];
}
}
for (const key in obj) {
if (result[key] === undefined) {
result[key] = obj[key];
}
}
return result;
}
/**
* Format a MapLibre Style. Returns a stringified style with its keys
* sorted in the same order as the reference style.
*
* The optional `space` argument is passed to
* [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
* to generate formatted output.
*
* If `space` is unspecified, a default of `2` spaces will be used.
*
* @private
* @param {Object} style a MapLibre Style
* @param {number} [space] space argument to pass to `JSON.stringify`
* @returns {string} stringified formatted JSON
* @example
* var fs = require('fs');
* var format = require('maplibre-gl-style-spec').format;
* var style = fs.readFileSync('./source.json', 'utf8');
* fs.writeFileSync('./dest.json', format(style));
* fs.writeFileSync('./dest.min.json', format(style, 0));
*/
function format(style, space = 2) {
style = sortKeysBy(style, latest.$root);
if (style.layers) {
style.layers = style.layers.map((layer) => sortKeysBy(layer, latest.layer));
}
return stringify(style, { indent: space });
}
const argv = minimist(process.argv.slice(2));
if (argv.help || argv.h || (!argv._.length && process.stdin.isTTY)) {
help();
} else {
console.log(format(JSON.parse(fs.readFileSync(argv._[0]).toString()), argv.space));
}
function help() {
console.log('usage:');
console.log(' gl-style-format source.json > destination.json');
console.log('');
console.log('options:');
console.log(' --space <num>');
console.log(' Number of spaces in output (default "2")');
console.log(' Pass "0" for minified output.');
}
//# sourceMappingURL=gl-style-format.mjs.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
{
"name": "@maplibre/maplibre-gl-style-spec",
"description": "a specification for maplibre styles",
"version": "24.8.1",
"author": "MapLibre",
"keywords": [
"mapbox",
"mapbox-gl",
"mapbox-gl-js",
"maplibre",
"maplibre-gl",
"maplibre-gl-js"
],
"license": "ISC",
"homepage": "https://maplibre.org/maplibre-style-spec/",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "rollup --configPlugin @rollup/plugin-typescript -c rollup.config.ts && cp ./src/reference/v8.json ./dist/latest.json",
"generate-style-spec": "node --no-warnings --loader ts-node/esm build/generate-style-spec.ts",
"generate-typings": "dts-bundle-generator -o ./dist/index.d.ts ./src/index.ts",
"generate-docs": "node ${WATCH+--watch} --no-warnings --loader ts-node/esm build/generate-docs.ts",
"start-docs": "docker run --rm -v ${PWD}:/docs zensical/zensical serve --open",
"docs": "npm run generate-docs && docker run --rm -v ${PWD}:/docs zensical/zensical build",
"test": "vitest",
"test-unit": "vitest run --config vitest.config.unit.ts",
"test-unit-ci": "vitest run --config vitest.config.unit.ts --coverage",
"test-integration": "vitest run --config vitest.config.integration.ts",
"test-integration-ci": "vitest run --config vitest.config.integration.ts --coverage",
"test-build": "vitest run --config vitest.config.build.ts",
"test-build-ci": "vitest run --config vitest.config.build.ts --coverage",
"test-watch-roots": "vitest --config vitest.config.unit.ts --watch",
"compile": "tsc",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"prepare": "npm run generate-style-spec",
"fmt": "oxfmt"
},
"repository": {
"type": "git",
"url": "https://github.com/maplibre/maplibre-style-spec"
},
"bin": {
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs",
"gl-style-format": "dist/gl-style-format.mjs"
},
"files": [
"dist",
"src",
"bin"
],
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"sideEffects": false,
"devDependencies": {
"oxfmt": "^0.42.0",
"@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-strip": "^3.0.4",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/eslint": "^9.6.1",
"@types/geojson": "^7946.0.16",
"@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.1",
"@vitest/coverage-v8": "4.1.2",
"@vitest/eslint-plugin": "^1.6.13",
"@vitest/ui": "4.1.2",
"dts-bundle-generator": "^9.5.1",
"eslint": "^10.1.0",
"eslint-plugin-jsdoc": "^62.8.1",
"glob": "^13.0.6",
"globals": "^17.4.0",
"rollup": "^4.60.0",
"rollup-plugin-preserve-shebang": "^1.0.1",
"semver": "^7.7.4",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vitest": "4.1.2"
}
}

View File

@@ -0,0 +1,52 @@
import {derefLayers, type LayerWithRef} from './deref';
import {describe, test, expect} from 'vitest';
describe('deref', () => {
test('derefs a ref layer which follows its parent', () => {
expect(
derefLayers([
{
id: 'parent',
type: 'line'
} as LayerWithRef,
{
id: 'child',
ref: 'parent'
} as LayerWithRef
])
).toEqual([
{
id: 'parent',
type: 'line'
},
{
id: 'child',
type: 'line'
}
]);
});
test('derefs a ref layer which precedes its parent', () => {
expect(
derefLayers([
{
id: 'child',
ref: 'parent'
} as LayerWithRef,
{
id: 'parent',
type: 'line'
} as LayerWithRef
])
).toEqual([
{
id: 'child',
type: 'line'
},
{
id: 'parent',
type: 'line'
}
]);
});
});

View File

@@ -0,0 +1,49 @@
import {refProperties} from './util/ref_properties';
import {LayerSpecification} from './types.g';
export type LayerWithRef = LayerSpecification & {ref?: string};
function deref(layer: LayerWithRef, parent: LayerSpecification): LayerSpecification {
const result: Partial<LayerSpecification> = {};
for (const k in layer) {
if (k !== 'ref') {
result[k] = layer[k];
}
}
refProperties.forEach((k) => {
if (k in parent) {
result[k] = parent[k];
}
});
return result as LayerSpecification;
}
/**
*
* The input is not modified. The output may contain references to portions
* of the input.
*
* @param layers - array of layers, some of which may contain `ref` properties
* whose value is the `id` of another property
* @returns a new array where such layers have been augmented with the 'type', 'source', etc. properties
* from the parent layer, and the `ref` property has been removed.
*/
export function derefLayers(layers: LayerWithRef[]): LayerSpecification[] {
layers = layers.slice();
const map = Object.create(null);
for (let i = 0; i < layers.length; i++) {
map[layers[i].id] = layers[i];
}
for (let i = 0; i < layers.length; i++) {
if ('ref' in layers[i]) {
layers[i] = deref(layers[i], map[layers[i].ref]);
}
}
return layers;
}

View File

@@ -0,0 +1,825 @@
import {diff} from './diff';
import {StyleSpecification} from './types.g';
import {describe, test, expect} from 'vitest';
describe('diff', () => {
test('layers id equal', () => {
expect(
diff(
{
layers: [{id: 'a'}]
} as StyleSpecification,
{
layers: [{id: 'a'}]
} as StyleSpecification
)
).toEqual([]);
});
test('version not equal', () => {
expect(
diff(
{
version: 7,
layers: [{id: 'a'}]
} as any as StyleSpecification,
{
version: 8,
layers: [{id: 'a'}]
} as StyleSpecification
)
).toEqual([{command: 'setStyle', args: [{version: 8, layers: [{id: 'a'}]}]}]);
});
test('add layer at the end', () => {
expect(
diff(
{
layers: [{id: 'a'}]
} as StyleSpecification,
{
layers: [{id: 'a'}, {id: 'b'}]
} as StyleSpecification
)
).toEqual([{command: 'addLayer', args: [{id: 'b'}, undefined]}]);
});
test('add layer at the beginning', () => {
expect(
diff(
{
layers: [{id: 'b'}]
} as StyleSpecification,
{
layers: [{id: 'a'}, {id: 'b'}]
} as StyleSpecification
)
).toEqual([{command: 'addLayer', args: [{id: 'a'}, 'b']}]);
});
test('remove layer', () => {
expect(
diff(
{
layers: [{id: 'a'}, {id: 'b', source: 'foo', nested: [1]}]
} as StyleSpecification,
{
layers: [{id: 'a'}]
} as StyleSpecification
)
).toEqual([{command: 'removeLayer', args: ['b']}]);
});
test('remove and add layer', () => {
expect(
diff(
{
layers: [{id: 'a'}, {id: 'b'}]
} as StyleSpecification,
{
layers: [{id: 'b'}, {id: 'a'}]
} as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['a']},
{command: 'addLayer', args: [{id: 'a'}, undefined]}
]);
});
test('set paint property', () => {
expect(
diff(
{
layers: [{id: 'a', paint: {foo: 1}}]
} as any as StyleSpecification,
{
layers: [{id: 'a', paint: {foo: 2}}]
} as any as StyleSpecification
)
).toEqual([{command: 'setPaintProperty', args: ['a', 'foo', 2, null]}]);
});
test('set paint property with light', () => {
expect(
diff(
{
layers: [{id: 'a', 'paint.light': {foo: 1}}]
} as any as StyleSpecification,
{
layers: [{id: 'a', 'paint.light': {foo: 2}}]
} as any as StyleSpecification
)
).toEqual([{command: 'setPaintProperty', args: ['a', 'foo', 2, 'light']}]);
});
test('set paint property with ramp', () => {
expect(
diff(
{
layers: [{id: 'a', paint: {foo: {ramp: [1, 2]}}}]
} as any as StyleSpecification,
{
layers: [{id: 'a', paint: {foo: {ramp: [1]}}}]
} as any as StyleSpecification
)
).toEqual([{command: 'setPaintProperty', args: ['a', 'foo', {ramp: [1]}, null]}]);
});
test('set layout property', () => {
expect(
diff(
{
layers: [{id: 'a', layout: {foo: 1}}]
} as any as StyleSpecification,
{
layers: [{id: 'a', layout: {foo: 2}}]
} as any as StyleSpecification
)
).toEqual([{command: 'setLayoutProperty', args: ['a', 'foo', 2, null]}]);
});
test('set filter', () => {
expect(
diff(
{
layers: [{id: 'a', filter: ['==', 'foo', 'bar']}]
} as StyleSpecification,
{
layers: [{id: 'a', filter: ['==', 'foo', 'baz']}]
} as StyleSpecification
)
).toEqual([{command: 'setFilter', args: ['a', ['==', 'foo', 'baz']]}]);
});
test('remove source', () => {
expect(
diff(
{
sources: {foo: 1}
} as any as StyleSpecification,
{
sources: {}
} as StyleSpecification
)
).toEqual([{command: 'removeSource', args: ['foo']}]);
});
test('add source', () => {
expect(
diff(
{
sources: {}
} as StyleSpecification,
{
sources: {foo: 1}
} as any as StyleSpecification
)
).toEqual([{command: 'addSource', args: ['foo', 1]}]);
});
test('set goejson source data', () => {
expect(
diff(
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []}
}
}
} as any as StyleSpecification,
{
sources: {
foo: {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {type: 'Point', coordinates: [10, 20]}
}
]
}
}
}
} as any as StyleSpecification
)
).toEqual([
{
command: 'setGeoJSONSourceData',
args: [
'foo',
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {type: 'Point', coordinates: [10, 20]}
}
]
}
]
}
]);
});
test('remove and add source', () => {
expect(
diff(
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []}
}
}
} as any as StyleSpecification,
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []},
cluster: true
}
}
} as any as StyleSpecification
)
).toEqual([
{command: 'removeSource', args: ['foo']},
{
command: 'addSource',
args: [
'foo',
{
type: 'geojson',
cluster: true,
data: {type: 'FeatureCollection', features: []}
}
]
}
]);
});
test('remove and add source with clusterRadius', () => {
expect(
diff(
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []},
cluster: true
}
}
} as any as StyleSpecification,
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []},
cluster: true,
clusterRadius: 100
}
}
} as any as StyleSpecification
)
).toEqual([
{command: 'removeSource', args: ['foo']},
{
command: 'addSource',
args: [
'foo',
{
type: 'geojson',
cluster: true,
clusterRadius: 100,
data: {type: 'FeatureCollection', features: []}
}
]
}
]);
});
test('remove and add source without clusterRadius', () => {
expect(
diff(
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []},
cluster: true,
clusterRadius: 100
}
}
} as any as StyleSpecification,
{
sources: {
foo: {
type: 'geojson',
data: {type: 'FeatureCollection', features: []},
cluster: true
}
}
} as any as StyleSpecification
)
).toEqual([
{command: 'removeSource', args: ['foo']},
{
command: 'addSource',
args: [
'foo',
{
type: 'geojson',
cluster: true,
data: {type: 'FeatureCollection', features: []}
}
]
}
]);
});
test('global metadata', () => {
expect(
diff(
{} as StyleSpecification,
{
metadata: {'maplibre:author': 'nobody'}
} as StyleSpecification
)
).toEqual([]);
});
test('layer metadata', () => {
expect(
diff(
{
layers: [{id: 'a', metadata: {'maplibre:group': 'Group Name'}}]
} as StyleSpecification,
{
layers: [{id: 'a', metadata: {'maplibre:group': 'Another Name'}}]
} as StyleSpecification
)
).toEqual([]);
});
test('set state', () => {
expect(
diff(
{
state: {foo: 1}
} as any as StyleSpecification,
{
state: {foo: 2}
} as any as StyleSpecification
)
).toEqual([{command: 'setGlobalState', args: [{foo: 2}]}]);
});
test('set center', () => {
expect(
diff(
{
center: [0, 0]
} as StyleSpecification,
{
center: [1, 1]
} as StyleSpecification
)
).toEqual([{command: 'setCenter', args: [[1, 1]]}]);
});
test('set centerAltitude to undefined', () => {
expect(
diff(
{
centerAltitude: 1
} as StyleSpecification,
{} as StyleSpecification
)
).toEqual([{command: 'setCenterAltitude', args: [undefined]}]);
});
test('set centerAltitude', () => {
expect(
diff(
{
centerAltitude: 0
} as StyleSpecification,
{
centerAltitude: 1
} as StyleSpecification
)
).toEqual([{command: 'setCenterAltitude', args: [1]}]);
});
test('set zoom', () => {
expect(
diff(
{
zoom: 12
} as StyleSpecification,
{
zoom: 15
} as StyleSpecification
)
).toEqual([{command: 'setZoom', args: [15]}]);
});
test('set bearing', () => {
expect(
diff(
{
bearing: 0
} as StyleSpecification,
{
bearing: 180
} as StyleSpecification
)
).toEqual([{command: 'setBearing', args: [180]}]);
});
test('set pitch', () => {
expect(
diff(
{
pitch: 0
} as StyleSpecification,
{
pitch: 1
} as StyleSpecification
)
).toEqual([{command: 'setPitch', args: [1]}]);
});
test('set roll to undefined', () => {
expect(
diff(
{
roll: 1
} as StyleSpecification,
{} as StyleSpecification
)
).toEqual([{command: 'setRoll', args: [undefined]}]);
});
test('set roll', () => {
expect(
diff(
{
roll: 0
} as StyleSpecification,
{
roll: 1
} as StyleSpecification
)
).toEqual([{command: 'setRoll', args: [1]}]);
});
test('no changes in light', () => {
expect(
diff(
{
light: {
anchor: 'map',
color: 'white',
position: [0, 1, 0],
intensity: 1
}
} as StyleSpecification,
{
light: {
anchor: 'map',
color: 'white',
position: [0, 1, 0],
intensity: 1
}
} as StyleSpecification
)
).toEqual([]);
});
test('set light anchor', () => {
expect(
diff(
{
light: {anchor: 'map'}
} as StyleSpecification,
{
light: {anchor: 'viewport'}
} as StyleSpecification
)
).toEqual([{command: 'setLight', args: [{anchor: 'viewport'}]}]);
});
test('set light color', () => {
expect(
diff(
{
light: {color: 'white'}
} as StyleSpecification,
{
light: {color: 'red'}
} as StyleSpecification
)
).toEqual([{command: 'setLight', args: [{color: 'red'}]}]);
});
test('set light position', () => {
expect(
diff(
{
light: {position: [0, 1, 0]}
} as StyleSpecification,
{
light: {position: [1, 0, 0]}
} as StyleSpecification
)
).toEqual([{command: 'setLight', args: [{position: [1, 0, 0]}]}]);
});
test('set light intensity', () => {
expect(
diff(
{
light: {intensity: 1}
} as StyleSpecification,
{
light: {intensity: 10}
} as StyleSpecification
)
).toEqual([{command: 'setLight', args: [{intensity: 10}]}]);
});
test('set light anchor and color', () => {
expect(
diff(
{
light: {
anchor: 'map',
color: 'orange',
position: [2, 80, 30],
intensity: 1.0
}
} as StyleSpecification,
{
light: {
anchor: 'map',
color: 'red',
position: [1, 40, 30],
intensity: 1.0
}
} as StyleSpecification
)
).toEqual([
{
command: 'setLight',
args: [
{
anchor: 'map',
color: 'red',
position: [1, 40, 30],
intensity: 1.0
}
]
}
]);
});
test('add and remove layer on source change', () => {
expect(
diff(
{
layers: [{id: 'a', source: 'source-one'}]
} as StyleSpecification,
{
layers: [{id: 'a', source: 'source-two'}]
} as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['a']},
{command: 'addLayer', args: [{id: 'a', source: 'source-two'}, undefined]}
]);
});
test('add and remove layer on type change', () => {
expect(
diff(
{
layers: [{id: 'a', type: 'fill'}]
} as StyleSpecification,
{
layers: [{id: 'a', type: 'line'}]
} as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['a']},
{command: 'addLayer', args: [{id: 'a', type: 'line'}, undefined]}
]);
});
test('add and remove layer on source-layer change', () => {
expect(
diff(
{
layers: [{id: 'a', source: 'a', 'source-layer': 'layer-one'}]
} as StyleSpecification,
{
layers: [{id: 'a', source: 'a', 'source-layer': 'layer-two'}]
} as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['a']},
{
command: 'addLayer',
args: [{id: 'a', source: 'a', 'source-layer': 'layer-two'}, undefined]
}
]);
});
test('add and remove layers on different order and type', () => {
expect(
diff(
{
layers: [{id: 'b'}, {id: 'c'}, {id: 'a', type: 'fill'}]
} as StyleSpecification,
{
layers: [{id: 'c'}, {id: 'a', type: 'line'}, {id: 'b'}]
} as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['b']},
{command: 'addLayer', args: [{id: 'b'}, undefined]},
{command: 'removeLayer', args: ['a']},
{command: 'addLayer', args: [{id: 'a', type: 'line'}, 'b']}
]);
});
test('add and remove layer and source on source data change', () => {
expect(
diff(
{
sources: {foo: {data: 1}, bar: {}},
layers: [
{id: 'a', source: 'bar'},
{id: 'b', source: 'foo'},
{id: 'c', source: 'bar'}
]
} as any as StyleSpecification,
{
sources: {foo: {data: 2}, bar: {}},
layers: [
{id: 'a', source: 'bar'},
{id: 'b', source: 'foo'},
{id: 'c', source: 'bar'}
]
} as any as StyleSpecification
)
).toEqual([
{command: 'removeLayer', args: ['b']},
{command: 'removeSource', args: ['foo']},
{command: 'addSource', args: ['foo', {data: 2}]},
{command: 'addLayer', args: [{id: 'b', source: 'foo'}, 'c']}
]);
});
test('set transition', () => {
expect(
diff(
{
sources: {foo: {data: 1}, bar: {}},
layers: [{id: 'a', source: 'bar'}]
} as any as StyleSpecification,
{
sources: {foo: {data: 1}, bar: {}},
layers: [{id: 'a', source: 'bar'}],
transition: 'transition'
} as any as StyleSpecification
)
).toEqual([{command: 'setTransition', args: ['transition']}]);
});
test('no sprite change', () => {
expect(
diff(
{
sprite: 'a'
} as StyleSpecification,
{
sprite: 'a'
} as StyleSpecification
)
).toEqual([]);
});
test('set sprite', () => {
expect(
diff(
{
sprite: 'a'
} as StyleSpecification,
{
sprite: 'b'
} as StyleSpecification
)
).toEqual([{command: 'setSprite', args: ['b']}]);
});
test('set sprite for multiple sprites', () => {
expect(
diff(
{
sprite: 'a'
} as StyleSpecification,
{
sprite: [{id: 'default', url: 'b'}]
} as StyleSpecification
)
).toEqual([{command: 'setSprite', args: [[{id: 'default', url: 'b'}]]}]);
});
test('no glyphs change', () => {
expect(
diff(
{
glyphs: 'a'
} as StyleSpecification,
{
glyphs: 'a'
} as StyleSpecification
)
).toEqual([]);
});
test('set glyphs', () => {
expect(
diff(
{
glyphs: 'a'
} as StyleSpecification,
{
glyphs: 'b'
} as StyleSpecification
)
).toEqual([{command: 'setGlyphs', args: ['b']}]);
});
test('remove terrain', () => {
expect(
diff(
{
terrain: {
source: 'maplibre-dem',
exaggeration: 1.5
}
} as StyleSpecification,
{} as StyleSpecification
)
).toEqual([{command: 'setTerrain', args: [undefined]}]);
});
test('add terrain', () => {
expect(
diff(
{} as StyleSpecification,
{
terrain: {
source: 'maplibre-dem',
exaggeration: 1.5
}
} as StyleSpecification
)
).toEqual([{command: 'setTerrain', args: [{source: 'maplibre-dem', exaggeration: 1.5}]}]);
});
test('set sky', () => {
expect(
diff(
{} as StyleSpecification,
{
sky: {
'fog-color': 'green',
'fog-ground-blend': 0.2
}
} as StyleSpecification
)
).toEqual([{command: 'setSky', args: [{'fog-color': 'green', 'fog-ground-blend': 0.2}]}]);
});
test('set projection', () => {
expect(
diff(
{} as StyleSpecification,
{
projection: {type: ['vertical-perspective', 'mercator', 0.5]}
} as StyleSpecification
)
).toEqual([
{
command: 'setProjection',
args: [{type: ['vertical-perspective', 'mercator', 0.5]}]
}
]);
});
});

View File

@@ -0,0 +1,485 @@
import {
GeoJSONSourceSpecification,
LayerSpecification,
LightSpecification,
ProjectionSpecification,
SkySpecification,
SourceSpecification,
SpriteSpecification,
StyleSpecification,
TerrainSpecification,
TransitionSpecification,
StateSpecification
} from './types.g';
import {deepEqual} from './util/deep_equal';
/**
* Operations that can be performed by the diff.
* Below are the operations and their arguments, the arguments should be aligned with the style methods in maplibre-gl-js.
*/
export type DiffOperationsMap = {
setStyle: [StyleSpecification];
addLayer: [LayerSpecification, string | null];
removeLayer: [string];
setPaintProperty: [string, string, unknown, string | null];
setLayoutProperty: [string, string, unknown, string | null];
setFilter: [string, unknown];
addSource: [string, SourceSpecification];
removeSource: [string];
setGeoJSONSourceData: [string, unknown];
setLayerZoomRange: [string, number, number];
setLayerProperty: [string, string, unknown];
setCenter: [number[]];
setCenterAltitude: [number];
setZoom: [number];
setBearing: [number];
setPitch: [number];
setRoll: [number];
setSprite: [SpriteSpecification];
setGlyphs: [string];
setTransition: [TransitionSpecification];
setLight: [LightSpecification];
setTerrain: [TerrainSpecification];
setSky: [SkySpecification];
setProjection: [ProjectionSpecification];
setGlobalState: [StateSpecification];
};
export type DiffOperations = keyof DiffOperationsMap;
export type DiffCommand<T extends DiffOperations> = {
command: T;
args: DiffOperationsMap[T];
};
/**
* The main reason for this method is to allow type check when adding a command to the array.
* @param commands - The commands array to add to
* @param command - The command to add
*/
function addCommand<T extends DiffOperations>(
commands: DiffCommand<DiffOperations>[],
command: DiffCommand<T>
) {
commands.push(command);
}
function addSource(
sourceId: string,
after: {[key: string]: SourceSpecification},
commands: DiffCommand<DiffOperations>[]
) {
addCommand(commands, {command: 'addSource', args: [sourceId, after[sourceId]]});
}
function removeSource(
sourceId: string,
commands: DiffCommand<DiffOperations>[],
sourcesRemoved: {[key: string]: boolean}
) {
addCommand(commands, {command: 'removeSource', args: [sourceId]});
sourcesRemoved[sourceId] = true;
}
function updateSource(
sourceId: string,
after: {[key: string]: SourceSpecification},
commands: DiffCommand<DiffOperations>[],
sourcesRemoved: {[key: string]: boolean}
) {
removeSource(sourceId, commands, sourcesRemoved);
addSource(sourceId, after, commands);
}
function canUpdateGeoJSON(
before: {[key: string]: SourceSpecification},
after: {[key: string]: SourceSpecification},
sourceId: string
) {
let prop;
for (prop in before[sourceId]) {
if (!Object.prototype.hasOwnProperty.call(before[sourceId], prop)) continue;
if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) {
return false;
}
}
for (prop in after[sourceId]) {
if (!Object.prototype.hasOwnProperty.call(after[sourceId], prop)) continue;
if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) {
return false;
}
}
return true;
}
function diffSources(
before: {[key: string]: SourceSpecification},
after: {[key: string]: SourceSpecification},
commands: DiffCommand<DiffOperations>[],
sourcesRemoved: {[key: string]: boolean}
) {
before = before || ({} as {[key: string]: SourceSpecification});
after = after || ({} as {[key: string]: SourceSpecification});
let sourceId: string;
// look for sources to remove
for (sourceId in before) {
if (!Object.prototype.hasOwnProperty.call(before, sourceId)) continue;
if (!Object.prototype.hasOwnProperty.call(after, sourceId)) {
removeSource(sourceId, commands, sourcesRemoved);
}
}
// look for sources to add/update
for (sourceId in after) {
if (!Object.prototype.hasOwnProperty.call(after, sourceId)) continue;
if (!Object.prototype.hasOwnProperty.call(before, sourceId)) {
addSource(sourceId, after, commands);
} else if (!deepEqual(before[sourceId], after[sourceId])) {
if (
before[sourceId].type === 'geojson' &&
after[sourceId].type === 'geojson' &&
canUpdateGeoJSON(before, after, sourceId)
) {
addCommand(commands, {
command: 'setGeoJSONSourceData',
args: [sourceId, (after[sourceId] as GeoJSONSourceSpecification).data]
});
} else {
// no update command, must remove then add
updateSource(sourceId, after, commands, sourcesRemoved);
}
}
}
}
function diffLayerPropertyChanges(
before: LayerSpecification['layout'] | LayerSpecification['paint'],
after: LayerSpecification['layout'] | LayerSpecification['paint'],
commands: DiffCommand<DiffOperations>[],
layerId: string,
klass: string | null,
command: 'setPaintProperty' | 'setLayoutProperty'
) {
before = before || ({} as LayerSpecification['layout'] | LayerSpecification['paint']);
after = after || ({} as LayerSpecification['layout'] | LayerSpecification['paint']);
for (const prop in before) {
if (!Object.prototype.hasOwnProperty.call(before, prop)) continue;
if (!deepEqual(before[prop], after[prop])) {
commands.push({command, args: [layerId, prop, after[prop], klass]});
}
}
for (const prop in after) {
if (
!Object.prototype.hasOwnProperty.call(after, prop) ||
Object.prototype.hasOwnProperty.call(before, prop)
)
continue;
if (!deepEqual(before[prop], after[prop])) {
commands.push({command, args: [layerId, prop, after[prop], klass]});
}
}
}
function pluckId(layer: LayerSpecification) {
return layer.id;
}
function indexById(group: {[key: string]: LayerSpecification}, layer: LayerSpecification) {
group[layer.id] = layer;
return group;
}
function diffLayers(
before: LayerSpecification[],
after: LayerSpecification[],
commands: DiffCommand<DiffOperations>[]
) {
before = before || [];
after = after || [];
// order of layers by id
const beforeOrder = before.map(pluckId);
const afterOrder = after.map(pluckId);
// index of layer by id
const beforeIndex = before.reduce(indexById, {});
const afterIndex = after.reduce(indexById, {});
// track order of layers as if they have been mutated
const tracker = beforeOrder.slice();
// layers that have been added do not need to be diffed
const clean = Object.create(null);
let layerId: string;
let beforeLayer: LayerSpecification & {source?: string; filter?: unknown};
let afterLayer: LayerSpecification & {source?: string; filter?: unknown};
let insertBeforeLayerId: string;
let prop: string;
// remove layers
for (let i = 0, d = 0; i < beforeOrder.length; i++) {
layerId = beforeOrder[i];
if (!Object.prototype.hasOwnProperty.call(afterIndex, layerId)) {
addCommand(commands, {command: 'removeLayer', args: [layerId]});
tracker.splice(tracker.indexOf(layerId, d), 1);
} else {
// limit where in tracker we need to look for a match
d++;
}
}
// add/reorder layers
for (let i = 0, d = 0; i < afterOrder.length; i++) {
// work backwards as insert is before an existing layer
layerId = afterOrder[afterOrder.length - 1 - i];
if (tracker[tracker.length - 1 - i] === layerId) continue;
if (Object.prototype.hasOwnProperty.call(beforeIndex, layerId)) {
// remove the layer before we insert at the correct position
addCommand(commands, {command: 'removeLayer', args: [layerId]});
tracker.splice(tracker.lastIndexOf(layerId, tracker.length - d), 1);
} else {
// limit where in tracker we need to look for a match
d++;
}
// add layer at correct position
insertBeforeLayerId = tracker[tracker.length - i];
addCommand(commands, {
command: 'addLayer',
args: [afterIndex[layerId], insertBeforeLayerId]
});
tracker.splice(tracker.length - i, 0, layerId);
clean[layerId] = true;
}
// update layers
for (let i = 0; i < afterOrder.length; i++) {
layerId = afterOrder[i];
beforeLayer = beforeIndex[layerId];
afterLayer = afterIndex[layerId];
// no need to update if previously added (new or moved)
if (clean[layerId] || deepEqual(beforeLayer, afterLayer)) continue;
// If source, source-layer, or type have changes, then remove the layer
// and add it back 'from scratch'.
if (
!deepEqual(beforeLayer.source, afterLayer.source) ||
!deepEqual(beforeLayer['source-layer'], afterLayer['source-layer']) ||
!deepEqual(beforeLayer.type, afterLayer.type)
) {
addCommand(commands, {command: 'removeLayer', args: [layerId]});
// we add the layer back at the same position it was already in, so
// there's no need to update the `tracker`
insertBeforeLayerId = tracker[tracker.lastIndexOf(layerId) + 1];
addCommand(commands, {command: 'addLayer', args: [afterLayer, insertBeforeLayerId]});
continue;
}
// layout, paint, filter, minzoom, maxzoom
diffLayerPropertyChanges(
beforeLayer.layout,
afterLayer.layout,
commands,
layerId,
null,
'setLayoutProperty'
);
diffLayerPropertyChanges(
beforeLayer.paint,
afterLayer.paint,
commands,
layerId,
null,
'setPaintProperty'
);
if (!deepEqual(beforeLayer.filter, afterLayer.filter)) {
addCommand(commands, {command: 'setFilter', args: [layerId, afterLayer.filter]});
}
if (
!deepEqual(beforeLayer.minzoom, afterLayer.minzoom) ||
!deepEqual(beforeLayer.maxzoom, afterLayer.maxzoom)
) {
addCommand(commands, {
command: 'setLayerZoomRange',
args: [layerId, afterLayer.minzoom, afterLayer.maxzoom]
});
}
// handle all other layer props, including paint.*
for (prop in beforeLayer) {
if (!Object.prototype.hasOwnProperty.call(beforeLayer, prop)) continue;
if (
prop === 'layout' ||
prop === 'paint' ||
prop === 'filter' ||
prop === 'metadata' ||
prop === 'minzoom' ||
prop === 'maxzoom'
)
continue;
if (prop.indexOf('paint.') === 0) {
diffLayerPropertyChanges(
beforeLayer[prop],
afterLayer[prop],
commands,
layerId,
prop.slice(6),
'setPaintProperty'
);
} else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) {
addCommand(commands, {
command: 'setLayerProperty',
args: [layerId, prop, afterLayer[prop]]
});
}
}
for (prop in afterLayer) {
if (
!Object.prototype.hasOwnProperty.call(afterLayer, prop) ||
Object.prototype.hasOwnProperty.call(beforeLayer, prop)
)
continue;
if (
prop === 'layout' ||
prop === 'paint' ||
prop === 'filter' ||
prop === 'metadata' ||
prop === 'minzoom' ||
prop === 'maxzoom'
)
continue;
if (prop.indexOf('paint.') === 0) {
diffLayerPropertyChanges(
beforeLayer[prop],
afterLayer[prop],
commands,
layerId,
prop.slice(6),
'setPaintProperty'
);
} else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) {
addCommand(commands, {
command: 'setLayerProperty',
args: [layerId, prop, afterLayer[prop]]
});
}
}
}
}
/**
* Diff two stylesheet
*
* Creates semanticly aware diffs that can easily be applied at runtime.
* Operations produced by the diff closely resemble the maplibre-gl-js API. Any
* error creating the diff will fall back to the 'setStyle' operation.
*
* Example diff:
* [
* { command: 'setConstant', args: ['@water', '#0000FF'] },
* { command: 'setPaintProperty', args: ['background', 'background-color', 'black'] }
* ]
*
* @private
* @param {*} [before] stylesheet to compare from
* @param {*} after stylesheet to compare to
* @returns Array list of changes
*/
export function diff(
before: StyleSpecification,
after: StyleSpecification
): DiffCommand<DiffOperations>[] {
if (!before) return [{command: 'setStyle', args: [after]}];
let commands: DiffCommand<DiffOperations>[] = [];
try {
// Handle changes to top-level properties
if (!deepEqual(before.version, after.version)) {
return [{command: 'setStyle', args: [after]}];
}
if (!deepEqual(before.center, after.center)) {
commands.push({command: 'setCenter', args: [after.center]});
}
if (!deepEqual(before.state, after.state)) {
commands.push({command: 'setGlobalState', args: [after.state]});
}
if (!deepEqual(before.centerAltitude, after.centerAltitude)) {
commands.push({command: 'setCenterAltitude', args: [after.centerAltitude]});
}
if (!deepEqual(before.zoom, after.zoom)) {
commands.push({command: 'setZoom', args: [after.zoom]});
}
if (!deepEqual(before.bearing, after.bearing)) {
commands.push({command: 'setBearing', args: [after.bearing]});
}
if (!deepEqual(before.pitch, after.pitch)) {
commands.push({command: 'setPitch', args: [after.pitch]});
}
if (!deepEqual(before.roll, after.roll)) {
commands.push({command: 'setRoll', args: [after.roll]});
}
if (!deepEqual(before.sprite, after.sprite)) {
commands.push({command: 'setSprite', args: [after.sprite]});
}
if (!deepEqual(before.glyphs, after.glyphs)) {
commands.push({command: 'setGlyphs', args: [after.glyphs]});
}
if (!deepEqual(before.transition, after.transition)) {
commands.push({command: 'setTransition', args: [after.transition]});
}
if (!deepEqual(before.light, after.light)) {
commands.push({command: 'setLight', args: [after.light]});
}
if (!deepEqual(before.terrain, after.terrain)) {
commands.push({command: 'setTerrain', args: [after.terrain]});
}
if (!deepEqual(before.sky, after.sky)) {
commands.push({command: 'setSky', args: [after.sky]});
}
if (!deepEqual(before.projection, after.projection)) {
commands.push({command: 'setProjection', args: [after.projection]});
}
// Handle changes to `sources`
// If a source is to be removed, we also--before the removeSource
// command--need to remove all the style layers that depend on it.
const sourcesRemoved = {};
// First collect the {add,remove}Source commands
const removeOrAddSourceCommands = [];
diffSources(before.sources, after.sources, removeOrAddSourceCommands, sourcesRemoved);
// Push a removeLayer command for each style layer that depends on a
// source that's being removed.
// Also, exclude any such layers them from the input to `diffLayers`
// below, so that diffLayers produces the appropriate `addLayers`
// command
const beforeLayers = [];
if (before.layers) {
before.layers.forEach((layer) => {
if ('source' in layer && sourcesRemoved[layer.source]) {
commands.push({command: 'removeLayer', args: [layer.id]});
} else {
beforeLayers.push(layer);
}
});
}
commands = commands.concat(removeOrAddSourceCommands);
// Handle changes to `layers`
diffLayers(beforeLayers, after.layers, commands);
} catch (e) {
// fall back to setStyle
console.warn('Unable to compute style diff:', e);
commands = [{command: 'setStyle', args: [after]}];
}
return commands;
}

View File

@@ -0,0 +1,15 @@
import {emptyStyle} from './empty';
import {validateStyleMin} from './validate_style.min';
import {describe, test, expect} from 'vitest';
describe('empty', () => {
test('it generates something', () => {
const style = emptyStyle();
expect(style).toBeTruthy();
});
test('generated empty style is a valid style', () => {
const errors = validateStyleMin(emptyStyle());
expect(errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,30 @@
import {latest} from './reference/latest';
import {StyleSpecification} from './types.g';
export function emptyStyle(): StyleSpecification {
const style = {};
const version = latest['$version'];
for (const styleKey in latest['$root']) {
const specification = latest['$root'][styleKey];
if (specification.required) {
let value = null;
if (styleKey === 'version') {
value = version;
} else {
if (specification.type === 'array') {
value = [];
} else {
value = {};
}
}
if (value != null) {
style[styleKey] = value;
}
}
}
return style as StyleSpecification;
}

View File

@@ -0,0 +1,14 @@
// Note: Do not inherit from Error. It breaks when transpiling to ES5.
export class ParsingError {
message: string;
error: Error;
line: number;
constructor(error: Error) {
this.error = error;
this.message = error.message;
const match = error.message.match(/line (\d+)/);
this.line = match ? parseInt(match[1], 10) : 0;
}
}

View File

@@ -0,0 +1,23 @@
// Note: Do not inherit from Error. It breaks when transpiling to ES5.
export class ValidationError {
message: string;
identifier: string;
line: number;
constructor(
key: string,
value: any & {
__line__: number;
},
message: string,
identifier?: string | null
) {
this.message = (key ? `${key}: ` : '') + message;
if (identifier) this.identifier = identifier;
if (value !== null && value !== undefined && value.__line__) {
this.line = value.__line__;
}
}
}

View File

@@ -0,0 +1,647 @@
import {
typeToString,
NumberType,
StringType,
BooleanType,
ColorType,
ObjectType,
ValueType,
ErrorType,
CollatorType,
array
} from './types';
import {ParsingContext} from './parsing_context';
import {EvaluationContext} from './evaluation_context';
import {expressions} from './definitions';
import {CollatorExpression} from './definitions/collator';
import {Within} from './definitions/within';
import {Literal} from './definitions/literal';
import {Assertion} from './definitions/assertion';
import {Coercion} from './definitions/coercion';
import {Var} from './definitions/var';
import {Distance} from './definitions/distance';
import {GlobalState} from './definitions/global_state';
import type {Expression, ExpressionRegistry} from './expression';
import type {Value} from './values';
import type {Type} from './types';
import {typeOf, validateRGBA, valueToString} from './values';
import {RuntimeError} from './runtime_error';
import {Color} from './types/color';
export type Varargs = {
type: Type;
};
type Signature = Array<Type> | Varargs;
type Evaluate = (b: EvaluationContext, a: Array<Expression>) => Value;
type Definition =
| [Type, Signature, Evaluate]
| {
type: Type;
overloads: Array<[Signature, Evaluate]>;
};
export class CompoundExpression implements Expression {
name: string;
type: Type;
_evaluate: Evaluate;
args: Array<Expression>;
static definitions: {[_: string]: Definition};
constructor(name: string, type: Type, evaluate: Evaluate, args: Array<Expression>) {
this.name = name;
this.type = type;
this._evaluate = evaluate;
this.args = args;
}
evaluate(ctx: EvaluationContext) {
return this._evaluate(ctx, this.args);
}
eachChild(fn: (_: Expression) => void) {
this.args.forEach(fn);
}
outputDefined() {
return false;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
const op: string = args[0] as any;
const definition = CompoundExpression.definitions[op];
if (!definition) {
return context.error(
`Unknown expression "${op}". If you wanted a literal array, use ["literal", [...]].`,
0
) as null;
}
// Now check argument types against each signature
const type = Array.isArray(definition) ? definition[0] : definition.type;
const availableOverloads = Array.isArray(definition)
? [[definition[1], definition[2]]]
: definition.overloads;
const overloads = availableOverloads.filter(
([signature]) =>
!Array.isArray(signature) || // varags
signature.length === args.length - 1 // correct param count
);
let signatureContext: ParsingContext = null;
for (const [params, evaluate] of overloads) {
// Use a fresh context for each attempted signature so that, if
// we eventually succeed, we haven't polluted `context.errors`.
signatureContext = new ParsingContext(
context.registry,
isExpressionConstant,
context.path,
null,
context.scope
);
// First parse all the args, potentially coercing to the
// types expected by this overload.
const parsedArgs: Array<Expression> = [];
let argParseFailed = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
const expectedType = Array.isArray(params)
? params[i - 1]
: (params as Varargs).type;
const parsed = signatureContext.parse(arg, 1 + parsedArgs.length, expectedType);
if (!parsed) {
argParseFailed = true;
break;
}
parsedArgs.push(parsed);
}
if (argParseFailed) {
// Couldn't coerce args of this overload to expected type, move
// on to next one.
continue;
}
if (Array.isArray(params)) {
if (params.length !== parsedArgs.length) {
signatureContext.error(
`Expected ${params.length} arguments, but found ${parsedArgs.length} instead.`
);
continue;
}
}
for (let i = 0; i < parsedArgs.length; i++) {
const expected = Array.isArray(params) ? params[i] : (params as Varargs).type;
const arg = parsedArgs[i];
signatureContext.concat(i + 1).checkSubtype(expected, arg.type);
}
if (signatureContext.errors.length === 0) {
return new CompoundExpression(op, type, evaluate as Evaluate, parsedArgs);
}
}
if (overloads.length === 1) {
context.errors.push(...signatureContext.errors);
} else {
const expected = overloads.length ? overloads : availableOverloads;
const signatures = expected
.map(([params]) => stringifySignature(params as Signature))
.join(' | ');
const actualTypes = [];
// For error message, re-parse arguments without trying to
// apply any coercions
for (let i = 1; i < args.length; i++) {
const parsed = context.parse(args[i], 1 + actualTypes.length);
if (!parsed) return null;
actualTypes.push(typeToString(parsed.type));
}
context.error(
`Expected arguments of type ${signatures}, but found (${actualTypes.join(', ')}) instead.`
);
}
return null;
}
static register(registry: ExpressionRegistry, definitions: {[_: string]: Definition}) {
CompoundExpression.definitions = definitions;
for (const name in definitions) {
registry[name] = CompoundExpression;
}
}
}
function rgba(ctx, [r, g, b, a]) {
r = r.evaluate(ctx);
g = g.evaluate(ctx);
b = b.evaluate(ctx);
const alpha = a ? a.evaluate(ctx) : 1;
const error = validateRGBA(r, g, b, alpha);
if (error) throw new RuntimeError(error);
return new Color(r / 255, g / 255, b / 255, alpha, false);
}
function has(key, obj) {
return key in obj;
}
function get(key, obj) {
const v = obj[key];
return typeof v === 'undefined' ? null : v;
}
function binarySearch(v, a, i, j) {
while (i <= j) {
const m = (i + j) >> 1;
if (a[m] === v) return true;
if (a[m] > v) j = m - 1;
else i = m + 1;
}
return false;
}
function varargs(type: Type): Varargs {
return {type};
}
CompoundExpression.register(expressions, {
error: [
ErrorType,
[StringType],
(ctx, [v]) => {
throw new RuntimeError(v.evaluate(ctx));
}
],
typeof: [StringType, [ValueType], (ctx, [v]) => typeToString(typeOf(v.evaluate(ctx)))],
'to-rgba': [
array(NumberType, 4),
[ColorType],
(ctx, [v]) => {
const [r, g, b, a] = v.evaluate(ctx).rgb;
return [r * 255, g * 255, b * 255, a];
}
],
rgb: [ColorType, [NumberType, NumberType, NumberType], rgba],
rgba: [ColorType, [NumberType, NumberType, NumberType, NumberType], rgba],
has: {
type: BooleanType,
overloads: [
[[StringType], (ctx, [key]) => has(key.evaluate(ctx), ctx.properties())],
[
[StringType, ObjectType],
(ctx, [key, obj]) => has(key.evaluate(ctx), obj.evaluate(ctx))
]
]
},
get: {
type: ValueType,
overloads: [
[[StringType], (ctx, [key]) => get(key.evaluate(ctx), ctx.properties())],
[
[StringType, ObjectType],
(ctx, [key, obj]) => get(key.evaluate(ctx), obj.evaluate(ctx))
]
]
},
'feature-state': [
ValueType,
[StringType],
(ctx, [key]) => get(key.evaluate(ctx), ctx.featureState || {})
],
properties: [ObjectType, [], (ctx) => ctx.properties()],
'geometry-type': [StringType, [], (ctx) => ctx.geometryType()],
id: [ValueType, [], (ctx) => ctx.id()],
zoom: [NumberType, [], (ctx) => ctx.globals.zoom],
'heatmap-density': [NumberType, [], (ctx) => ctx.globals.heatmapDensity || 0],
elevation: [NumberType, [], (ctx) => ctx.globals.elevation || 0],
'line-progress': [NumberType, [], (ctx) => ctx.globals.lineProgress || 0],
accumulated: [
ValueType,
[],
(ctx) => (ctx.globals.accumulated === undefined ? null : ctx.globals.accumulated)
],
'+': [
NumberType,
varargs(NumberType),
(ctx, args) => {
let result = 0;
for (const arg of args) {
result += arg.evaluate(ctx);
}
return result;
}
],
'*': [
NumberType,
varargs(NumberType),
(ctx, args) => {
let result = 1;
for (const arg of args) {
result *= arg.evaluate(ctx);
}
return result;
}
],
'-': {
type: NumberType,
overloads: [
[[NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) - b.evaluate(ctx)],
[[NumberType], (ctx, [a]) => -a.evaluate(ctx)]
]
},
'/': [NumberType, [NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) / b.evaluate(ctx)],
'%': [NumberType, [NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) % b.evaluate(ctx)],
ln2: [NumberType, [], () => Math.LN2],
pi: [NumberType, [], () => Math.PI],
e: [NumberType, [], () => Math.E],
'^': [
NumberType,
[NumberType, NumberType],
(ctx, [b, e]) => Math.pow(b.evaluate(ctx), e.evaluate(ctx))
],
sqrt: [NumberType, [NumberType], (ctx, [x]) => Math.sqrt(x.evaluate(ctx))],
log10: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx)) / Math.LN10],
ln: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx))],
log2: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx)) / Math.LN2],
sin: [NumberType, [NumberType], (ctx, [n]) => Math.sin(n.evaluate(ctx))],
cos: [NumberType, [NumberType], (ctx, [n]) => Math.cos(n.evaluate(ctx))],
tan: [NumberType, [NumberType], (ctx, [n]) => Math.tan(n.evaluate(ctx))],
asin: [NumberType, [NumberType], (ctx, [n]) => Math.asin(n.evaluate(ctx))],
acos: [NumberType, [NumberType], (ctx, [n]) => Math.acos(n.evaluate(ctx))],
atan: [NumberType, [NumberType], (ctx, [n]) => Math.atan(n.evaluate(ctx))],
min: [
NumberType,
varargs(NumberType),
(ctx, args) => Math.min(...args.map((arg) => arg.evaluate(ctx)))
],
max: [
NumberType,
varargs(NumberType),
(ctx, args) => Math.max(...args.map((arg) => arg.evaluate(ctx)))
],
abs: [NumberType, [NumberType], (ctx, [n]) => Math.abs(n.evaluate(ctx))],
round: [
NumberType,
[NumberType],
(ctx, [n]) => {
const v = n.evaluate(ctx);
// Javascript's Math.round() rounds towards +Infinity for halfway
// values, even when they're negative. It's more common to round
// away from 0 (e.g., this is what python and C++ do)
return v < 0 ? -Math.round(-v) : Math.round(v);
}
],
floor: [NumberType, [NumberType], (ctx, [n]) => Math.floor(n.evaluate(ctx))],
ceil: [NumberType, [NumberType], (ctx, [n]) => Math.ceil(n.evaluate(ctx))],
'filter-==': [
BooleanType,
[StringType, ValueType],
(ctx, [k, v]) => ctx.properties()[(k as any).value] === (v as any).value
],
'filter-id-==': [BooleanType, [ValueType], (ctx, [v]) => ctx.id() === (v as any).value],
'filter-type-==': [
BooleanType,
[StringType],
(ctx, [v]) => ctx.geometryType() === (v as any).value
],
'filter-<': [
BooleanType,
[StringType, ValueType],
(ctx, [k, v]) => {
const a = ctx.properties()[(k as any).value];
const b = (v as any).value;
return typeof a === typeof b && a < b;
}
],
'filter-id-<': [
BooleanType,
[ValueType],
(ctx, [v]) => {
const a = ctx.id();
const b = (v as any).value;
return typeof a === typeof b && a < b;
}
],
'filter->': [
BooleanType,
[StringType, ValueType],
(ctx, [k, v]) => {
const a = ctx.properties()[(k as any).value];
const b = (v as any).value;
return typeof a === typeof b && a > b;
}
],
'filter-id->': [
BooleanType,
[ValueType],
(ctx, [v]) => {
const a = ctx.id();
const b = (v as any).value;
return typeof a === typeof b && a > b;
}
],
'filter-<=': [
BooleanType,
[StringType, ValueType],
(ctx, [k, v]) => {
const a = ctx.properties()[(k as any).value];
const b = (v as any).value;
return typeof a === typeof b && a <= b;
}
],
'filter-id-<=': [
BooleanType,
[ValueType],
(ctx, [v]) => {
const a = ctx.id();
const b = (v as any).value;
return typeof a === typeof b && a <= b;
}
],
'filter->=': [
BooleanType,
[StringType, ValueType],
(ctx, [k, v]) => {
const a = ctx.properties()[(k as any).value];
const b = (v as any).value;
return typeof a === typeof b && a >= b;
}
],
'filter-id->=': [
BooleanType,
[ValueType],
(ctx, [v]) => {
const a = ctx.id();
const b = (v as any).value;
return typeof a === typeof b && a >= b;
}
],
'filter-has': [BooleanType, [ValueType], (ctx, [k]) => (k as any).value in ctx.properties()],
'filter-has-id': [BooleanType, [], (ctx) => ctx.id() !== null && ctx.id() !== undefined],
'filter-type-in': [
BooleanType,
[array(StringType)],
(ctx, [v]) => (v as any).value.indexOf(ctx.geometryType()) >= 0
],
'filter-id-in': [
BooleanType,
[array(ValueType)],
(ctx, [v]) => (v as any).value.indexOf(ctx.id()) >= 0
],
'filter-in-small': [
BooleanType,
[StringType, array(ValueType)],
// assumes v is an array literal
(ctx, [k, v]) => (v as any).value.indexOf(ctx.properties()[(k as any).value]) >= 0
],
'filter-in-large': [
BooleanType,
[StringType, array(ValueType)],
// assumes v is a array literal with values sorted in ascending order and of a single type
(ctx, [k, v]) =>
binarySearch(
ctx.properties()[(k as any).value],
(v as any).value,
0,
(v as any).value.length - 1
)
],
all: {
type: BooleanType,
overloads: [
[[BooleanType, BooleanType], (ctx, [a, b]) => a.evaluate(ctx) && b.evaluate(ctx)],
[
varargs(BooleanType),
(ctx, args) => {
for (const arg of args) {
if (!arg.evaluate(ctx)) return false;
}
return true;
}
]
]
},
any: {
type: BooleanType,
overloads: [
[[BooleanType, BooleanType], (ctx, [a, b]) => a.evaluate(ctx) || b.evaluate(ctx)],
[
varargs(BooleanType),
(ctx, args) => {
for (const arg of args) {
if (arg.evaluate(ctx)) return true;
}
return false;
}
]
]
},
'!': [BooleanType, [BooleanType], (ctx, [b]) => !b.evaluate(ctx)],
'is-supported-script': [
BooleanType,
[StringType],
// At parse time this will always return true, so we need to exclude this expression with isGlobalPropertyConstant
(ctx, [s]) => {
const isSupportedScript = ctx.globals && ctx.globals.isSupportedScript;
if (isSupportedScript) {
return isSupportedScript(s.evaluate(ctx));
}
return true;
}
],
upcase: [StringType, [StringType], (ctx, [s]) => s.evaluate(ctx).toUpperCase()],
downcase: [StringType, [StringType], (ctx, [s]) => s.evaluate(ctx).toLowerCase()],
concat: [
StringType,
varargs(ValueType),
(ctx, args) => args.map((arg) => valueToString(arg.evaluate(ctx))).join('')
],
split: [
array(StringType),
[StringType, StringType],
(ctx, [s, delim]) => s.evaluate(ctx).split(delim.evaluate(ctx))
],
join: [
StringType,
[array(StringType), StringType],
(ctx, [arr, delim]) => arr.evaluate(ctx).join(delim.evaluate(ctx))
],
'resolved-locale': [
StringType,
[CollatorType],
(ctx, [collator]) => collator.evaluate(ctx).resolvedLocale()
]
});
function stringifySignature(signature: Signature): string {
if (Array.isArray(signature)) {
return `(${signature.map(typeToString).join(', ')})`;
} else {
return `(${typeToString(signature.type)}...)`;
}
}
function isExpressionConstant(expression: Expression) {
if (expression instanceof Var) {
return isExpressionConstant(expression.boundExpression);
} else if (expression instanceof CompoundExpression && expression.name === 'error') {
return false;
} else if (expression instanceof CollatorExpression) {
// Although the results of a Collator expression with fixed arguments
// generally shouldn't change between executions, we can't serialize them
// as constant expressions because results change based on environment.
return false;
} else if (expression instanceof Within) {
return false;
} else if (expression instanceof Distance) {
return false;
} else if (expression instanceof GlobalState) {
return false;
}
const isTypeAnnotation = expression instanceof Coercion || expression instanceof Assertion;
let childrenConstant = true;
expression.eachChild((child) => {
// We can _almost_ assume that if `expressions` children are constant,
// they would already have been evaluated to Literal values when they
// were parsed. Type annotations are the exception, because they might
// have been inferred and added after a child was parsed.
// So we recurse into isConstant() for the children of type annotations,
// but otherwise simply check whether they are Literals.
if (isTypeAnnotation) {
childrenConstant = childrenConstant && isExpressionConstant(child);
} else {
childrenConstant = childrenConstant && child instanceof Literal;
}
});
if (!childrenConstant) {
return false;
}
return (
isFeatureConstant(expression) &&
isGlobalPropertyConstant(expression, [
'zoom',
'heatmap-density',
'elevation',
'line-progress',
'accumulated',
'is-supported-script'
])
);
}
function isFeatureConstant(e: Expression) {
if (e instanceof CompoundExpression) {
if (e.name === 'get' && e.args.length === 1) {
return false;
} else if (e.name === 'feature-state') {
return false;
} else if (e.name === 'has' && e.args.length === 1) {
return false;
} else if (e.name === 'properties' || e.name === 'geometry-type' || e.name === 'id') {
return false;
} else if (/^filter-/.test(e.name)) {
return false;
}
}
if (e instanceof Within) {
return false;
}
if (e instanceof Distance) {
return false;
}
let result = true;
e.eachChild((arg) => {
if (result && !isFeatureConstant(arg)) {
result = false;
}
});
return result;
}
function isStateConstant(e: Expression) {
if (e instanceof CompoundExpression) {
if (e.name === 'feature-state') {
return false;
}
}
let result = true;
e.eachChild((arg) => {
if (result && !isStateConstant(arg)) {
result = false;
}
});
return result;
}
function isGlobalPropertyConstant(e: Expression, properties: Array<string>) {
if (e instanceof CompoundExpression && properties.indexOf(e.name) >= 0) {
return false;
}
let result = true;
e.eachChild((arg) => {
if (result && !isGlobalPropertyConstant(arg, properties)) {
result = false;
}
});
return result;
}
export {isFeatureConstant, isGlobalPropertyConstant, isStateConstant, isExpressionConstant};

View File

@@ -0,0 +1,111 @@
import {
ObjectType,
ValueType,
StringType,
NumberType,
BooleanType,
checkSubtype,
typeToString,
array
} from '../types';
import {RuntimeError} from '../runtime_error';
import {typeOf} from '../values';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
const types = {
string: StringType,
number: NumberType,
boolean: BooleanType,
object: ObjectType
};
export class Assertion implements Expression {
type: Type;
args: Array<Expression>;
constructor(type: Type, args: Array<Expression>) {
this.type = type;
this.args = args;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 2) return context.error('Expected at least one argument.') as null;
let i = 1;
let type;
const name: string = args[0] as any;
if (name === 'array') {
let itemType;
if (args.length > 2) {
const type = args[1];
if (typeof type !== 'string' || !(type in types) || type === 'object')
return context.error(
'The item type argument of "array" must be one of string, number, boolean',
1
) as null;
itemType = types[type];
i++;
} else {
itemType = ValueType;
}
let N;
if (args.length > 3) {
if (
args[2] !== null &&
(typeof args[2] !== 'number' || args[2] < 0 || args[2] !== Math.floor(args[2]))
) {
return context.error(
'The length argument to "array" must be a positive integer literal',
2
) as null;
}
N = args[2];
i++;
}
type = array(itemType, N);
} else {
if (!types[name]) throw new Error(`Types doesn't contain name = ${name}`);
type = types[name];
}
const parsed = [];
for (; i < args.length; i++) {
const input = context.parse(args[i], i, ValueType);
if (!input) return null;
parsed.push(input);
}
return new Assertion(type, parsed);
}
evaluate(ctx: EvaluationContext) {
for (let i = 0; i < this.args.length; i++) {
const value = this.args[i].evaluate(ctx);
const error = checkSubtype(this.type, typeOf(value));
if (!error) {
return value;
} else if (i === this.args.length - 1) {
throw new RuntimeError(
`Expected value to be of type ${typeToString(this.type)}, but found ${typeToString(typeOf(value))} instead.`
);
}
}
throw new Error();
}
eachChild(fn: (_: Expression) => void) {
this.args.forEach(fn);
}
outputDefined(): boolean {
return this.args.every((arg) => arg.outputDefined());
}
}

View File

@@ -0,0 +1,64 @@
import {array, ValueType, NumberType} from '../types';
import {RuntimeError} from '../runtime_error';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type, ArrayType} from '../types';
import type {Value} from '../values';
export class At implements Expression {
type: Type;
index: Expression;
input: Expression;
constructor(type: Type, index: Expression, input: Expression) {
this.type = type;
this.index = index;
this.input = input;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 3)
return context.error(
`Expected 2 arguments, but found ${args.length - 1} instead.`
) as null;
const index = context.parse(args[1], 1, NumberType);
const input = context.parse(args[2], 2, array(context.expectedType || ValueType));
if (!index || !input) return null;
const t: ArrayType = input.type as any;
return new At(t.itemType, index, input);
}
evaluate(ctx: EvaluationContext) {
const index = this.index.evaluate(ctx) as any as number;
const array = this.input.evaluate(ctx) as any as Array<Value>;
if (index < 0) {
throw new RuntimeError(`Array index out of bounds: ${index} < 0.`);
}
if (index >= array.length) {
throw new RuntimeError(`Array index out of bounds: ${index} > ${array.length - 1}.`);
}
if (index !== Math.floor(index)) {
throw new RuntimeError(`Array index must be an integer, but found ${index} instead.`);
}
return array[index];
}
eachChild(fn: (_: Expression) => void) {
fn(this.index);
fn(this.input);
}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,77 @@
import {BooleanType} from '../types';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
type Branches = Array<[Expression, Expression]>;
export class Case implements Expression {
type: Type;
branches: Branches;
otherwise: Expression;
constructor(type: Type, branches: Branches, otherwise: Expression) {
this.type = type;
this.branches = branches;
this.otherwise = otherwise;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 4)
return context.error(
`Expected at least 3 arguments, but found only ${args.length - 1}.`
) as null;
if (args.length % 2 !== 0)
return context.error('Expected an odd number of arguments.') as null;
let outputType: Type;
if (context.expectedType && context.expectedType.kind !== 'value') {
outputType = context.expectedType;
}
const branches = [];
for (let i = 1; i < args.length - 1; i += 2) {
const test = context.parse(args[i], i, BooleanType);
if (!test) return null;
const result = context.parse(args[i + 1], i + 1, outputType);
if (!result) return null;
branches.push([test, result]);
outputType = outputType || result.type;
}
const otherwise = context.parse(args[args.length - 1], args.length - 1, outputType);
if (!otherwise) return null;
if (!outputType) throw new Error("Can't infer output type");
return new Case(outputType as any, branches, otherwise);
}
evaluate(ctx: EvaluationContext) {
for (const [test, expression] of this.branches) {
if (test.evaluate(ctx)) {
return expression.evaluate(ctx);
}
}
return this.otherwise.evaluate(ctx);
}
eachChild(fn: (_: Expression) => void) {
for (const [test, expression] of this.branches) {
fn(test);
fn(expression);
}
fn(this.otherwise);
}
outputDefined(): boolean {
return (
this.branches.every(([_, out]) => out.outputDefined()) && this.otherwise.outputDefined()
);
}
}

View File

@@ -0,0 +1,83 @@
import {checkSubtype, ValueType} from '../types';
import {ResolvedImage} from '../types/resolved_image';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class Coalesce implements Expression {
type: Type;
args: Array<Expression>;
constructor(type: Type, args: Array<Expression>) {
this.type = type;
this.args = args;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 2) {
return context.error('Expected at least one argument.') as null;
}
let outputType: Type = null;
const expectedType = context.expectedType;
if (expectedType && expectedType.kind !== 'value') {
outputType = expectedType;
}
const parsedArgs = [];
for (const arg of args.slice(1)) {
const parsed = context.parse(arg, 1 + parsedArgs.length, outputType, undefined, {
typeAnnotation: 'omit'
});
if (!parsed) return null;
outputType = outputType || parsed.type;
parsedArgs.push(parsed);
}
if (!outputType) throw new Error('No output type');
// Above, we parse arguments without inferred type annotation so that
// they don't produce a runtime error for `null` input, which would
// preempt the desired null-coalescing behavior.
// Thus, if any of our arguments would have needed an annotation, we
// need to wrap the enclosing coalesce expression with it instead.
const needsAnnotation =
expectedType && parsedArgs.some((arg) => checkSubtype(expectedType, arg.type));
return needsAnnotation
? new Coalesce(ValueType, parsedArgs)
: new Coalesce(outputType as any, parsedArgs);
}
evaluate(ctx: EvaluationContext) {
let result = null;
let argCount = 0;
let requestedImageName;
for (const arg of this.args) {
argCount++;
result = arg.evaluate(ctx);
// we need to keep track of the first requested image in a coalesce statement
// if coalesce can't find a valid image, we return the first image name so styleimagemissing can fire
if (result && result instanceof ResolvedImage && !result.available) {
if (!requestedImageName) {
requestedImageName = result.name;
}
result = null;
if (argCount === this.args.length) {
result = requestedImageName;
}
}
if (result !== null) break;
}
return result;
}
eachChild(fn: (_: Expression) => void) {
this.args.forEach(fn);
}
outputDefined(): boolean {
return this.args.every((arg) => arg.outputDefined());
}
}

View File

@@ -0,0 +1,184 @@
import {BooleanType, ColorType, NumberType, StringType, ValueType} from '../types';
import {valueToString, validateRGBA} from '../values';
import {RuntimeError} from '../runtime_error';
import {Formatted} from '../types/formatted';
import {ResolvedImage} from '../types/resolved_image';
import {Color} from '../types/color';
import {Padding} from '../types/padding';
import {NumberArray} from '../types/number_array';
import {ColorArray} from '../types/color_array';
import {VariableAnchorOffsetCollection} from '../types/variable_anchor_offset_collection';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
const types = {
'to-boolean': BooleanType,
'to-color': ColorType,
'to-number': NumberType,
'to-string': StringType
};
/**
* Special form for error-coalescing coercion expressions "to-number",
* "to-color". Since these coercions can fail at runtime, they accept multiple
* arguments, only evaluating one at a time until one succeeds.
*
* @private
*/
export class Coercion implements Expression {
type: Type;
args: Array<Expression>;
constructor(type: Type, args: Array<Expression>) {
this.type = type;
this.args = args;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 2) return context.error('Expected at least one argument.') as null;
const name: string = args[0] as any;
if (!types[name])
throw new Error(`Can't parse ${name} as it is not part of the known types`);
if ((name === 'to-boolean' || name === 'to-string') && args.length !== 2)
return context.error('Expected one argument.') as null;
const type = types[name];
const parsed = [];
for (let i = 1; i < args.length; i++) {
const input = context.parse(args[i], i, ValueType);
if (!input) return null;
parsed.push(input);
}
return new Coercion(type, parsed);
}
evaluate(ctx: EvaluationContext) {
switch (this.type.kind) {
case 'boolean':
return Boolean(this.args[0].evaluate(ctx));
case 'color': {
let input;
let error;
for (const arg of this.args) {
input = arg.evaluate(ctx);
error = null;
if (input instanceof Color) {
return input;
} else if (typeof input === 'string') {
const c = ctx.parseColor(input);
if (c) return c;
} else if (Array.isArray(input)) {
if (input.length < 3 || input.length > 4) {
error = `Invalid rgba value ${JSON.stringify(input)}: expected an array containing either three or four numeric values.`;
} else {
error = validateRGBA(input[0], input[1], input[2], input[3]);
}
if (!error) {
return new Color(
(input[0] as any) / 255,
(input[1] as any) / 255,
(input[2] as any) / 255,
input[3] as any
);
}
}
}
throw new RuntimeError(
error ||
`Could not parse color from value '${typeof input === 'string' ? input : JSON.stringify(input)}'`
);
}
case 'padding': {
let input;
for (const arg of this.args) {
input = arg.evaluate(ctx);
const pad = Padding.parse(input);
if (pad) {
return pad;
}
}
throw new RuntimeError(
`Could not parse padding from value '${typeof input === 'string' ? input : JSON.stringify(input)}'`
);
}
case 'numberArray': {
let input;
for (const arg of this.args) {
input = arg.evaluate(ctx);
const val = NumberArray.parse(input);
if (val) {
return val;
}
}
throw new RuntimeError(
`Could not parse numberArray from value '${typeof input === 'string' ? input : JSON.stringify(input)}'`
);
}
case 'colorArray': {
let input;
for (const arg of this.args) {
input = arg.evaluate(ctx);
const val = ColorArray.parse(input);
if (val) {
return val;
}
}
throw new RuntimeError(
`Could not parse colorArray from value '${typeof input === 'string' ? input : JSON.stringify(input)}'`
);
}
case 'variableAnchorOffsetCollection': {
let input;
for (const arg of this.args) {
input = arg.evaluate(ctx);
const coll = VariableAnchorOffsetCollection.parse(input);
if (coll) {
return coll;
}
}
throw new RuntimeError(
`Could not parse variableAnchorOffsetCollection from value '${typeof input === 'string' ? input : JSON.stringify(input)}'`
);
}
case 'number': {
let value = null;
for (const arg of this.args) {
value = arg.evaluate(ctx);
if (value === null) return 0;
const num = Number(value);
if (isNaN(num)) continue;
return num;
}
throw new RuntimeError(`Could not convert ${JSON.stringify(value)} to number.`);
}
case 'formatted':
// There is no explicit 'to-formatted' but this coercion can be implicitly
// created by properties that expect the 'formatted' type.
return Formatted.fromString(valueToString(this.args[0].evaluate(ctx)));
case 'resolvedImage':
return ResolvedImage.fromString(valueToString(this.args[0].evaluate(ctx)));
case 'projectionDefinition':
return this.args[0].evaluate(ctx);
default:
return valueToString(this.args[0].evaluate(ctx));
}
}
eachChild(fn: (_: Expression) => void) {
this.args.forEach(fn);
}
outputDefined(): boolean {
return this.args.every((arg) => arg.outputDefined());
}
}

View File

@@ -0,0 +1,79 @@
import {StringType, BooleanType, CollatorType} from '../types';
import {Collator} from '../types/collator';
import type {Expression} from '../expression';
import type {EvaluationContext} from '../evaluation_context';
import type {ParsingContext} from '../parsing_context';
import type {Type} from '../types';
export class CollatorExpression implements Expression {
type: Type;
caseSensitive: Expression;
diacriticSensitive: Expression;
locale: Expression | null;
constructor(
caseSensitive: Expression,
diacriticSensitive: Expression,
locale: Expression | null
) {
this.type = CollatorType;
this.locale = locale;
this.caseSensitive = caseSensitive;
this.diacriticSensitive = diacriticSensitive;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2) return context.error('Expected one argument.') as null;
const options = args[1] as any;
if (typeof options !== 'object' || Array.isArray(options))
return context.error('Collator options argument must be an object.') as null;
const caseSensitive = context.parse(
options['case-sensitive'] === undefined ? false : options['case-sensitive'],
1,
BooleanType
);
if (!caseSensitive) return null;
const diacriticSensitive = context.parse(
options['diacritic-sensitive'] === undefined ? false : options['diacritic-sensitive'],
1,
BooleanType
);
if (!diacriticSensitive) return null;
let locale = null;
if (options['locale']) {
locale = context.parse(options['locale'], 1, StringType);
if (!locale) return null;
}
return new CollatorExpression(caseSensitive, diacriticSensitive, locale);
}
evaluate(ctx: EvaluationContext) {
return new Collator(
this.caseSensitive.evaluate(ctx),
this.diacriticSensitive.evaluate(ctx),
this.locale ? this.locale.evaluate(ctx) : null
);
}
eachChild(fn: (_: Expression) => void) {
fn(this.caseSensitive);
fn(this.diacriticSensitive);
if (this.locale) {
fn(this.locale);
}
}
outputDefined() {
// Technically the set of possible outputs is the combinatoric set of Collators produced
// by all possible outputs of locale/caseSensitive/diacriticSensitive
// But for the primary use of Collators in comparison operators, we ignore the Collator's
// possible outputs anyway, so we can get away with leaving this false for now.
return false;
}
}

View File

@@ -0,0 +1,214 @@
import {typeToString, ValueType, BooleanType, CollatorType} from '../types';
import {Assertion} from './assertion';
import {typeOf} from '../values';
import {RuntimeError} from '../runtime_error';
import type {Expression} from '../expression';
import type {EvaluationContext} from '../evaluation_context';
import type {ParsingContext} from '../parsing_context';
import type {Type} from '../types';
type ComparisonOperator = '==' | '!=' | '<' | '>' | '<=' | '>=';
function isComparableType(op: ComparisonOperator, type: Type) {
if (op === '==' || op === '!=') {
// equality operator
return (
type.kind === 'boolean' ||
type.kind === 'string' ||
type.kind === 'number' ||
type.kind === 'null' ||
type.kind === 'value'
);
} else {
// ordering operator
return type.kind === 'string' || type.kind === 'number' || type.kind === 'value';
}
}
function eq(ctx, a, b) {
return a === b;
}
function neq(ctx, a, b) {
return a !== b;
}
function lt(ctx, a, b) {
return a < b;
}
function gt(ctx, a, b) {
return a > b;
}
function lteq(ctx, a, b) {
return a <= b;
}
function gteq(ctx, a, b) {
return a >= b;
}
function eqCollate(ctx, a, b, c) {
return c.compare(a, b) === 0;
}
function neqCollate(ctx, a, b, c) {
return !eqCollate(ctx, a, b, c);
}
function ltCollate(ctx, a, b, c) {
return c.compare(a, b) < 0;
}
function gtCollate(ctx, a, b, c) {
return c.compare(a, b) > 0;
}
function lteqCollate(ctx, a, b, c) {
return c.compare(a, b) <= 0;
}
function gteqCollate(ctx, a, b, c) {
return c.compare(a, b) >= 0;
}
/**
* Special form for comparison operators, implementing the signatures:
* - (T, T, ?Collator) => boolean
* - (T, value, ?Collator) => boolean
* - (value, T, ?Collator) => boolean
*
* For inequalities, T must be either value, string, or number. For ==/!=, it
* can also be boolean or null.
*
* Equality semantics are equivalent to Javascript's strict equality (===/!==)
* -- i.e., when the arguments' types don't match, == evaluates to false, != to
* true.
*
* When types don't match in an ordering comparison, a runtime error is thrown.
*
* @private
*/
function makeComparison(op: ComparisonOperator, compareBasic, compareWithCollator) {
const isOrderComparison = op !== '==' && op !== '!=';
return class Comparison implements Expression {
type: Type;
lhs: Expression;
rhs: Expression;
collator: Expression;
hasUntypedArgument: boolean;
constructor(lhs: Expression, rhs: Expression, collator?: Expression | null) {
this.type = BooleanType;
this.lhs = lhs;
this.rhs = rhs;
this.collator = collator;
this.hasUntypedArgument = lhs.type.kind === 'value' || rhs.type.kind === 'value';
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 3 && args.length !== 4)
return context.error('Expected two or three arguments.') as null;
const op: ComparisonOperator = args[0] as any;
let lhs = context.parse(args[1], 1, ValueType);
if (!lhs) return null;
if (!isComparableType(op, lhs.type)) {
return context
.concat(1)
.error(
`"${op}" comparisons are not supported for type '${typeToString(lhs.type)}'.`
) as null;
}
let rhs = context.parse(args[2], 2, ValueType);
if (!rhs) return null;
if (!isComparableType(op, rhs.type)) {
return context
.concat(2)
.error(
`"${op}" comparisons are not supported for type '${typeToString(rhs.type)}'.`
) as null;
}
if (
lhs.type.kind !== rhs.type.kind &&
lhs.type.kind !== 'value' &&
rhs.type.kind !== 'value'
) {
return context.error(
`Cannot compare types '${typeToString(lhs.type)}' and '${typeToString(rhs.type)}'.`
) as null;
}
if (isOrderComparison) {
// typing rules specific to less/greater than operators
if (lhs.type.kind === 'value' && rhs.type.kind !== 'value') {
// (value, T)
lhs = new Assertion(rhs.type, [lhs]);
} else if (lhs.type.kind !== 'value' && rhs.type.kind === 'value') {
// (T, value)
rhs = new Assertion(lhs.type, [rhs]);
}
}
let collator = null;
if (args.length === 4) {
if (
lhs.type.kind !== 'string' &&
rhs.type.kind !== 'string' &&
lhs.type.kind !== 'value' &&
rhs.type.kind !== 'value'
) {
return context.error(
'Cannot use collator to compare non-string types.'
) as null;
}
collator = context.parse(args[3], 3, CollatorType);
if (!collator) return null;
}
return new Comparison(lhs, rhs, collator);
}
evaluate(ctx: EvaluationContext) {
const lhs = this.lhs.evaluate(ctx);
const rhs = this.rhs.evaluate(ctx);
if (isOrderComparison && this.hasUntypedArgument) {
const lt = typeOf(lhs);
const rt = typeOf(rhs);
// check that type is string or number, and equal
if (lt.kind !== rt.kind || !(lt.kind === 'string' || lt.kind === 'number')) {
throw new RuntimeError(
`Expected arguments for "${op}" to be (string, string) or (number, number), but found (${lt.kind}, ${rt.kind}) instead.`
);
}
}
if (this.collator && !isOrderComparison && this.hasUntypedArgument) {
const lt = typeOf(lhs);
const rt = typeOf(rhs);
if (lt.kind !== 'string' || rt.kind !== 'string') {
return compareBasic(ctx, lhs, rhs);
}
}
return this.collator
? compareWithCollator(ctx, lhs, rhs, this.collator.evaluate(ctx))
: compareBasic(ctx, lhs, rhs);
}
eachChild(fn: (_: Expression) => void) {
fn(this.lhs);
fn(this.rhs);
if (this.collator) {
fn(this.collator);
}
}
outputDefined(): boolean {
return true;
}
};
}
export const Equals = makeComparison('==', eq, eqCollate);
export const NotEquals = makeComparison('!=', neq, neqCollate);
export const LessThan = makeComparison('<', lt, ltCollate);
export const GreaterThan = makeComparison('>', gt, gtCollate);
export const LessThanOrEqual = makeComparison('<=', lteq, lteqCollate);
export const GreaterThanOrEqual = makeComparison('>=', gteq, gteqCollate);

View File

@@ -0,0 +1,781 @@
import TinyQueue from 'tinyqueue';
import {Expression} from '../expression';
import {ParsingContext} from '../parsing_context';
import {NumberType, Type} from '../types';
import {isValue} from '../values';
import {EvaluationContext} from '../evaluation_context';
import {
BBox,
boxWithinBox,
getLngLatFromTileCoord,
pointWithinPolygon,
segmentIntersectSegment,
updateBBox
} from '../../util/geometry_util';
import {classifyRings} from '../../util/classify_rings';
import {CheapRuler} from '../../util/cheap_ruler';
type SimpleGeometry = GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point;
const MinPointsSize = 100;
const MinLinePointsSize = 50;
type IndexRange = [number, number];
type DistPair = [number, IndexRange, IndexRange];
function compareDistPair(a: DistPair, b: DistPair): number {
return b[0] - a[0];
}
function getRangeSize(range: IndexRange) {
return range[1] - range[0] + 1;
}
function isRangeSafe(range: IndexRange, threshold: number): boolean {
return range[1] >= range[0] && range[1] < threshold;
}
function splitRange(range: IndexRange, isLine: boolean): [IndexRange, IndexRange] {
if (range[0] > range[1]) {
return [null, null];
}
const size = getRangeSize(range);
if (isLine) {
if (size === 2) {
return [range, null];
}
const size1 = Math.floor(size / 2);
return [
[range[0], range[0] + size1],
[range[0] + size1, range[1]]
];
}
if (size === 1) {
return [range, null];
}
const size1 = Math.floor(size / 2) - 1;
return [
[range[0], range[0] + size1],
[range[0] + size1 + 1, range[1]]
];
}
function getBBox(coords: [number, number][], range: IndexRange): BBox {
if (!isRangeSafe(range, coords.length)) {
return [Infinity, Infinity, -Infinity, -Infinity];
}
const bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
for (let i = range[0]; i <= range[1]; ++i) {
updateBBox(bbox, coords[i]);
}
return bbox;
}
function getPolygonBBox(polygon: [number, number][][]): BBox {
const bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
for (const ring of polygon) {
for (const coord of ring) {
updateBBox(bbox, coord);
}
}
return bbox;
}
function isValidBBox(bbox: BBox): boolean {
return (
bbox[0] !== -Infinity &&
bbox[1] !== -Infinity &&
bbox[2] !== Infinity &&
bbox[3] !== Infinity
);
}
// Calculate the distance between two bounding boxes.
// Calculate the delta in x and y direction, and use two fake points {0.0, 0.0}
// and {dx, dy} to calculate the distance. Distance will be 0.0 if bounding box are overlapping.
function bboxToBBoxDistance(bbox1: BBox, bbox2: BBox, ruler: CheapRuler): number {
if (!isValidBBox(bbox1) || !isValidBBox(bbox2)) {
return NaN;
}
let dx = 0.0;
let dy = 0.0;
// bbox1 in left side
if (bbox1[2] < bbox2[0]) {
dx = bbox2[0] - bbox1[2];
}
// bbox1 in right side
if (bbox1[0] > bbox2[2]) {
dx = bbox1[0] - bbox2[2];
}
// bbox1 in above side
if (bbox1[1] > bbox2[3]) {
dy = bbox1[1] - bbox2[3];
}
// bbox1 in down side
if (bbox1[3] < bbox2[1]) {
dy = bbox2[1] - bbox1[3];
}
return ruler.distance([0.0, 0.0], [dx, dy]);
}
function pointToLineDistance(
point: [number, number],
line: [number, number][],
ruler: CheapRuler
): number {
const nearestPoint = ruler.pointOnLine(line, point);
return ruler.distance(point, nearestPoint.point);
}
function segmentToSegmentDistance(
p1: [number, number],
p2: [number, number],
q1: [number, number],
q2: [number, number],
ruler: CheapRuler
): number {
const dist1 = Math.min(
pointToLineDistance(p1, [q1, q2], ruler),
pointToLineDistance(p2, [q1, q2], ruler)
);
const dist2 = Math.min(
pointToLineDistance(q1, [p1, p2], ruler),
pointToLineDistance(q2, [p1, p2], ruler)
);
return Math.min(dist1, dist2);
}
function lineToLineDistance(
line1: [number, number][],
range1: IndexRange,
line2: [number, number][],
range2: IndexRange,
ruler: CheapRuler
): number {
const rangeSafe = isRangeSafe(range1, line1.length) && isRangeSafe(range2, line2.length);
if (!rangeSafe) {
return Infinity;
}
let dist = Infinity;
for (let i = range1[0]; i < range1[1]; ++i) {
const p1 = line1[i];
const p2 = line1[i + 1];
for (let j = range2[0]; j < range2[1]; ++j) {
const q1 = line2[j];
const q2 = line2[j + 1];
if (segmentIntersectSegment(p1, p2, q1, q2)) {
return 0.0;
}
dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler));
}
}
return dist;
}
function pointsToPointsDistance(
points1: [number, number][],
range1: IndexRange,
points2: [number, number][],
range2: IndexRange,
ruler: CheapRuler
): number {
const rangeSafe = isRangeSafe(range1, points1.length) && isRangeSafe(range2, points2.length);
if (!rangeSafe) {
return NaN;
}
let dist = Infinity;
for (let i = range1[0]; i <= range1[1]; ++i) {
for (let j = range2[0]; j <= range2[1]; ++j) {
dist = Math.min(dist, ruler.distance(points1[i], points2[j]));
if (dist === 0.0) {
return dist;
}
}
}
return dist;
}
function pointToPolygonDistance(
point: [number, number],
polygon: [number, number][][],
ruler: CheapRuler
): number {
if (pointWithinPolygon(point, polygon, true)) {
return 0.0;
}
let dist = Infinity;
for (const ring of polygon) {
const front = ring[0];
const back = ring[ring.length - 1];
if (front !== back) {
dist = Math.min(dist, pointToLineDistance(point, [back, front], ruler));
if (dist === 0.0) {
return dist;
}
}
const nearestPoint = ruler.pointOnLine(ring, point);
dist = Math.min(dist, ruler.distance(point, nearestPoint.point));
if (dist === 0.0) {
return dist;
}
}
return dist;
}
function lineToPolygonDistance(
line: [number, number][],
range: IndexRange,
polygon: [number, number][][],
ruler: CheapRuler
): number {
if (!isRangeSafe(range, line.length)) {
return NaN;
}
for (let i = range[0]; i <= range[1]; ++i) {
if (pointWithinPolygon(line[i], polygon, true)) {
return 0.0;
}
}
let dist = Infinity;
for (let i = range[0]; i < range[1]; ++i) {
const p1 = line[i];
const p2 = line[i + 1];
for (const ring of polygon) {
for (let j = 0, len = ring.length, k = len - 1; j < len; k = j++) {
const q1 = ring[k];
const q2 = ring[j];
if (segmentIntersectSegment(p1, p2, q1, q2)) {
return 0.0;
}
dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler));
}
}
}
return dist;
}
function polygonIntersect(poly1: [number, number][][], poly2: [number, number][][]): boolean {
for (const ring of poly1) {
for (const point of ring) {
if (pointWithinPolygon(point, poly2, true)) {
return true;
}
}
}
return false;
}
function polygonToPolygonDistance(
polygon1: [number, number][][],
polygon2: [number, number][][],
ruler,
currentMiniDist = Infinity
): number {
const bbox1 = getPolygonBBox(polygon1);
const bbox2 = getPolygonBBox(polygon2);
if (
currentMiniDist !== Infinity &&
bboxToBBoxDistance(bbox1, bbox2, ruler) >= currentMiniDist
) {
return currentMiniDist;
}
if (boxWithinBox(bbox1, bbox2)) {
if (polygonIntersect(polygon1, polygon2)) {
return 0.0;
}
} else if (polygonIntersect(polygon2, polygon1)) {
return 0.0;
}
let dist = Infinity;
for (const ring1 of polygon1) {
for (let i = 0, len1 = ring1.length, l = len1 - 1; i < len1; l = i++) {
const p1 = ring1[l];
const p2 = ring1[i];
for (const ring2 of polygon2) {
for (let j = 0, len2 = ring2.length, k = len2 - 1; j < len2; k = j++) {
const q1 = ring2[k];
const q2 = ring2[j];
if (segmentIntersectSegment(p1, p2, q1, q2)) {
return 0.0;
}
dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler));
}
}
}
}
return dist;
}
function updateQueue(
distQueue: TinyQueue<DistPair>,
miniDist: number,
ruler: CheapRuler,
points: [number, number][],
polyBBox: BBox,
rangeA?: IndexRange
) {
if (!rangeA) {
return;
}
const tempDist = bboxToBBoxDistance(getBBox(points, rangeA), polyBBox, ruler);
// Insert new pair to the queue if the bbox distance is less than
// miniDist, The pair with biggest distance will be at the top
if (tempDist < miniDist) {
distQueue.push([tempDist, rangeA, [0, 0]]);
}
}
function updateQueueTwoSets(
distQueue: TinyQueue<DistPair>,
miniDist: number,
ruler: CheapRuler,
pointSet1: [number, number][],
pointSet2: [number, number][],
range1?: IndexRange,
range2?: IndexRange
) {
if (!range1 || !range2) {
return;
}
const tempDist = bboxToBBoxDistance(
getBBox(pointSet1, range1),
getBBox(pointSet2, range2),
ruler
);
// Insert new pair to the queue if the bbox distance is less than
// miniDist, The pair with biggest distance will be at the top
if (tempDist < miniDist) {
distQueue.push([tempDist, range1, range2]);
}
}
// Divide and conquer, the time complexity is O(n*lgn), faster than Brute force
// O(n*n) Most of the time, use index for in-place processing.
function pointsToPolygonDistance(
points: [number, number][],
isLine: boolean,
polygon: [number, number][][],
ruler: CheapRuler,
currentMiniDist = Infinity
) {
let miniDist = Math.min(ruler.distance(points[0], polygon[0][0]), currentMiniDist);
if (miniDist === 0.0) {
return miniDist;
}
const distQueue = new TinyQueue<DistPair>(
[[0, [0, points.length - 1], [0, 0]]],
compareDistPair
);
const polyBBox = getPolygonBBox(polygon);
while (distQueue.length > 0) {
const distPair = distQueue.pop();
if (distPair[0] >= miniDist) {
continue;
}
const range = distPair[1];
// In case the set size are relatively small, we could use brute-force directly
const threshold = isLine ? MinLinePointsSize : MinPointsSize;
if (getRangeSize(range) <= threshold) {
if (!isRangeSafe(range, points.length)) {
return NaN;
}
if (isLine) {
const tempDist = lineToPolygonDistance(points, range, polygon, ruler);
if (isNaN(tempDist) || tempDist === 0.0) {
return tempDist;
}
miniDist = Math.min(miniDist, tempDist);
} else {
for (let i = range[0]; i <= range[1]; ++i) {
const tempDist = pointToPolygonDistance(points[i], polygon, ruler);
miniDist = Math.min(miniDist, tempDist);
if (miniDist === 0.0) {
return 0.0;
}
}
}
} else {
const newRangesA = splitRange(range, isLine);
updateQueue(distQueue, miniDist, ruler, points, polyBBox, newRangesA[0]);
updateQueue(distQueue, miniDist, ruler, points, polyBBox, newRangesA[1]);
}
}
return miniDist;
}
function pointSetToPointSetDistance(
pointSet1: [number, number][],
isLine1: boolean,
pointSet2: [number, number][],
isLine2: boolean,
ruler: CheapRuler,
currentMiniDist = Infinity
): number {
let miniDist = Math.min(currentMiniDist, ruler.distance(pointSet1[0], pointSet2[0]));
if (miniDist === 0.0) {
return miniDist;
}
const distQueue = new TinyQueue<DistPair>(
[[0, [0, pointSet1.length - 1], [0, pointSet2.length - 1]]],
compareDistPair
);
while (distQueue.length > 0) {
const distPair = distQueue.pop();
if (distPair[0] >= miniDist) {
continue;
}
const rangeA = distPair[1];
const rangeB = distPair[2];
const threshold1 = isLine1 ? MinLinePointsSize : MinPointsSize;
const threshold2 = isLine2 ? MinLinePointsSize : MinPointsSize;
// In case the set size are relatively small, we could use brute-force directly
if (getRangeSize(rangeA) <= threshold1 && getRangeSize(rangeB) <= threshold2) {
if (!isRangeSafe(rangeA, pointSet1.length) && isRangeSafe(rangeB, pointSet2.length)) {
return NaN;
}
let tempDist: number;
if (isLine1 && isLine2) {
tempDist = lineToLineDistance(pointSet1, rangeA, pointSet2, rangeB, ruler);
miniDist = Math.min(miniDist, tempDist);
} else if (isLine1 && !isLine2) {
const sublibe = pointSet1.slice(rangeA[0], rangeA[1] + 1);
for (let i = rangeB[0]; i <= rangeB[1]; ++i) {
tempDist = pointToLineDistance(pointSet2[i], sublibe, ruler);
miniDist = Math.min(miniDist, tempDist);
if (miniDist === 0.0) {
return miniDist;
}
}
} else if (!isLine1 && isLine2) {
const sublibe = pointSet2.slice(rangeB[0], rangeB[1] + 1);
for (let i = rangeA[0]; i <= rangeA[1]; ++i) {
tempDist = pointToLineDistance(pointSet1[i], sublibe, ruler);
miniDist = Math.min(miniDist, tempDist);
if (miniDist === 0.0) {
return miniDist;
}
}
} else {
tempDist = pointsToPointsDistance(pointSet1, rangeA, pointSet2, rangeB, ruler);
miniDist = Math.min(miniDist, tempDist);
}
} else {
const newRangesA = splitRange(rangeA, isLine1);
const newRangesB = splitRange(rangeB, isLine2);
updateQueueTwoSets(
distQueue,
miniDist,
ruler,
pointSet1,
pointSet2,
newRangesA[0],
newRangesB[0]
);
updateQueueTwoSets(
distQueue,
miniDist,
ruler,
pointSet1,
pointSet2,
newRangesA[0],
newRangesB[1]
);
updateQueueTwoSets(
distQueue,
miniDist,
ruler,
pointSet1,
pointSet2,
newRangesA[1],
newRangesB[0]
);
updateQueueTwoSets(
distQueue,
miniDist,
ruler,
pointSet1,
pointSet2,
newRangesA[1],
newRangesB[1]
);
}
}
return miniDist;
}
function pointToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) {
const tilePoints = ctx.geometry();
const pointPosition = tilePoints
.flat()
.map((p) => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]);
if (tilePoints.length === 0) {
return NaN;
}
const ruler = new CheapRuler(pointPosition[0][1]);
let dist = Infinity;
for (const geometry of geometries) {
switch (geometry.type) {
case 'Point':
dist = Math.min(
dist,
pointSetToPointSetDistance(
pointPosition,
false,
[geometry.coordinates as [number, number]],
false,
ruler,
dist
)
);
break;
case 'LineString':
dist = Math.min(
dist,
pointSetToPointSetDistance(
pointPosition,
false,
geometry.coordinates as [number, number][],
true,
ruler,
dist
)
);
break;
case 'Polygon':
dist = Math.min(
dist,
pointsToPolygonDistance(
pointPosition,
false,
geometry.coordinates as [number, number][][],
ruler,
dist
)
);
break;
}
if (dist === 0.0) {
return dist;
}
}
return dist;
}
function lineStringToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) {
const tileLine = ctx.geometry();
const linePositions = tileLine
.flat()
.map((p) => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]);
if (tileLine.length === 0) {
return NaN;
}
const ruler = new CheapRuler(linePositions[0][1]);
let dist = Infinity;
for (const geometry of geometries) {
switch (geometry.type) {
case 'Point':
dist = Math.min(
dist,
pointSetToPointSetDistance(
linePositions,
true,
[geometry.coordinates as [number, number]],
false,
ruler,
dist
)
);
break;
case 'LineString':
dist = Math.min(
dist,
pointSetToPointSetDistance(
linePositions,
true,
geometry.coordinates as [number, number][],
true,
ruler,
dist
)
);
break;
case 'Polygon':
dist = Math.min(
dist,
pointsToPolygonDistance(
linePositions,
true,
geometry.coordinates as [number, number][][],
ruler,
dist
)
);
break;
}
if (dist === 0.0) {
return dist;
}
}
return dist;
}
function polygonToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) {
const tilePolygon = ctx.geometry();
if (tilePolygon.length === 0 || tilePolygon[0].length === 0) {
return NaN;
}
const polygons = classifyRings(tilePolygon, 0).map((polygon) => {
return polygon.map((ring) => {
return ring.map(
(p) => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]
);
});
});
const ruler = new CheapRuler(polygons[0][0][0][1]);
let dist = Infinity;
for (const geometry of geometries) {
for (const polygon of polygons) {
switch (geometry.type) {
case 'Point':
dist = Math.min(
dist,
pointsToPolygonDistance(
[geometry.coordinates as [number, number]],
false,
polygon,
ruler,
dist
)
);
break;
case 'LineString':
dist = Math.min(
dist,
pointsToPolygonDistance(
geometry.coordinates as [number, number][],
true,
polygon,
ruler,
dist
)
);
break;
case 'Polygon':
dist = Math.min(
dist,
polygonToPolygonDistance(
polygon,
geometry.coordinates as [number, number][][],
ruler,
dist
)
);
break;
}
if (dist === 0.0) {
return dist;
}
}
}
return dist;
}
function toSimpleGeometry(
geometry: Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollection>
): SimpleGeometry[] {
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.map((polygon) => {
return {
type: 'Polygon',
coordinates: polygon
};
});
}
if (geometry.type === 'MultiLineString') {
return geometry.coordinates.map((lineString) => {
return {
type: 'LineString',
coordinates: lineString
};
});
}
if (geometry.type === 'MultiPoint') {
return geometry.coordinates.map((point) => {
return {
type: 'Point',
coordinates: point
};
});
}
return [geometry];
}
export class Distance implements Expression {
type: Type;
geojson: GeoJSON.GeoJSON;
geometries: SimpleGeometry[];
constructor(geojson: GeoJSON.GeoJSON, geometries: SimpleGeometry[]) {
this.type = NumberType;
this.geojson = geojson;
this.geometries = geometries;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2)
return context.error(
`'distance' expression requires exactly one argument, but found ${args.length - 1} instead.`
) as null;
if (isValue(args[1])) {
const geojson = args[1] as any;
if (geojson.type === 'FeatureCollection') {
return new Distance(
geojson,
geojson.features.map((feature) => toSimpleGeometry(feature.geometry)).flat()
);
} else if (geojson.type === 'Feature') {
return new Distance(geojson, toSimpleGeometry(geojson.geometry));
} else if ('type' in geojson && 'coordinates' in geojson) {
return new Distance(geojson, toSimpleGeometry(geojson));
}
}
return context.error(
"'distance' expression requires valid geojson object that contains polygon geometry type."
) as null;
}
evaluate(ctx: EvaluationContext) {
if (ctx.geometry() != null && ctx.canonicalID() != null) {
if (ctx.geometryType() === 'Point') {
return pointToGeometryDistance(ctx, this.geometries);
} else if (ctx.geometryType() === 'LineString') {
return lineStringToGeometryDistance(ctx, this.geometries);
} else if (ctx.geometryType() === 'Polygon') {
return polygonToGeometryDistance(ctx, this.geometries);
}
}
return NaN;
}
eachChild() {}
outputDefined(): boolean {
return true;
}
}

View File

@@ -0,0 +1,177 @@
import {
NumberType,
ValueType,
FormattedType,
array,
StringType,
ColorType,
ResolvedImageType
} from '../types';
import {
Formatted,
FormattedSection,
VERTICAL_ALIGN_OPTIONS,
VerticalAlign
} from '../types/formatted';
import {valueToString, typeOf} from '../values';
import type {Expression} from '../expression';
import type {EvaluationContext} from '../evaluation_context';
import type {ParsingContext} from '../parsing_context';
import type {Type} from '../types';
type FormattedSectionExpression = {
// Content of a section may be Image expression or other
// type of expression that is coercable to 'string'.
content: Expression;
scale: Expression | null;
font: Expression | null;
textColor: Expression | null;
verticalAlign: Expression | null;
};
export class FormatExpression implements Expression {
type: Type;
sections: Array<FormattedSectionExpression>;
constructor(sections: Array<FormattedSectionExpression>) {
this.type = FormattedType;
this.sections = sections;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 2) {
return context.error('Expected at least one argument.') as null;
}
const firstArg = args[1];
if (!Array.isArray(firstArg) && typeof firstArg === 'object') {
return context.error('First argument must be an image or text section.') as null;
}
const sections: Array<FormattedSectionExpression> = [];
let nextTokenMayBeObject = false;
for (let i = 1; i <= args.length - 1; ++i) {
const arg = args[i] as any;
if (nextTokenMayBeObject && typeof arg === 'object' && !Array.isArray(arg)) {
nextTokenMayBeObject = false;
let scale = null;
if (arg['font-scale']) {
scale = context.parse(arg['font-scale'], 1, NumberType);
if (!scale) return null;
}
let font = null;
if (arg['text-font']) {
font = context.parse(arg['text-font'], 1, array(StringType));
if (!font) return null;
}
let textColor = null;
if (arg['text-color']) {
textColor = context.parse(arg['text-color'], 1, ColorType);
if (!textColor) return null;
}
let verticalAlign = null;
if (arg['vertical-align']) {
if (
typeof arg['vertical-align'] === 'string' &&
!VERTICAL_ALIGN_OPTIONS.includes(arg['vertical-align'] as VerticalAlign)
) {
return context.error(
`'vertical-align' must be one of: 'bottom', 'center', 'top' but found '${arg['vertical-align']}' instead.`
) as null;
}
verticalAlign = context.parse(arg['vertical-align'], 1, StringType);
if (!verticalAlign) return null;
}
const lastExpression = sections[sections.length - 1];
lastExpression.scale = scale;
lastExpression.font = font;
lastExpression.textColor = textColor;
lastExpression.verticalAlign = verticalAlign;
} else {
const content = context.parse(args[i], 1, ValueType);
if (!content) return null;
const kind = content.type.kind;
if (
kind !== 'string' &&
kind !== 'value' &&
kind !== 'null' &&
kind !== 'resolvedImage'
)
return context.error(
"Formatted text type must be 'string', 'value', 'image' or 'null'."
) as null;
nextTokenMayBeObject = true;
sections.push({
content,
scale: null,
font: null,
textColor: null,
verticalAlign: null
});
}
}
return new FormatExpression(sections);
}
evaluate(ctx: EvaluationContext) {
const evaluateSection = (section) => {
const evaluatedContent = section.content.evaluate(ctx);
if (typeOf(evaluatedContent) === ResolvedImageType) {
return new FormattedSection(
'',
evaluatedContent,
null,
null,
null,
section.verticalAlign ? section.verticalAlign.evaluate(ctx) : null
);
}
return new FormattedSection(
valueToString(evaluatedContent),
null,
section.scale ? section.scale.evaluate(ctx) : null,
section.font ? section.font.evaluate(ctx).join(',') : null,
section.textColor ? section.textColor.evaluate(ctx) : null,
section.verticalAlign ? section.verticalAlign.evaluate(ctx) : null
);
};
return new Formatted(this.sections.map(evaluateSection));
}
eachChild(fn: (_: Expression) => void) {
for (const section of this.sections) {
fn(section.content);
if (section.scale) {
fn(section.scale);
}
if (section.font) {
fn(section.font);
}
if (section.textColor) {
fn(section.textColor);
}
if (section.verticalAlign) {
fn(section.verticalAlign);
}
}
}
outputDefined() {
// Technically the combinatoric set of all children
// Usually, this.text will be undefined anyway
return false;
}
}

View File

@@ -0,0 +1,51 @@
import {Type, ValueType} from '../types';
import type {Expression} from '../expression';
import {ParsingContext} from '../parsing_context';
import {EvaluationContext} from '../evaluation_context';
import {getOwn} from '../../util/get_own';
export class GlobalState implements Expression {
type: Type;
key: string;
constructor(key: string) {
this.type = ValueType;
this.key = key;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2) {
return context.error(
`Expected 1 argument, but found ${args.length - 1} instead.`
) as null;
}
const key = args[1];
if (key === undefined || key === null) {
return context.error('Global state property must be defined.') as null;
}
if (typeof key !== 'string') {
return context.error(
`Global state property must be string, but found ${typeof args[1]} instead.`
) as null;
}
return new GlobalState(key);
}
evaluate(ctx: EvaluationContext) {
const globalState = ctx.globals?.globalState;
if (!globalState || Object.keys(globalState).length === 0) return null;
return getOwn(globalState, this.key);
}
eachChild() {}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,47 @@
import {ResolvedImageType, StringType} from '../types';
import {ResolvedImage} from '../types/resolved_image';
import type {Expression} from '../expression';
import type {EvaluationContext} from '../evaluation_context';
import type {ParsingContext} from '../parsing_context';
import type {Type} from '../types';
export class ImageExpression implements Expression {
type: Type;
input: Expression;
constructor(input: Expression) {
this.type = ResolvedImageType;
this.input = input;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2) {
return context.error('Expected two arguments.') as null;
}
const name = context.parse(args[1], 1, StringType);
if (!name) return context.error('No image name provided.') as null;
return new ImageExpression(name);
}
evaluate(ctx: EvaluationContext) {
const evaluatedImageName = this.input.evaluate(ctx);
const value = ResolvedImage.fromString(evaluatedImageName);
if (value && ctx.availableImages)
value.available = ctx.availableImages.indexOf(evaluatedImageName) > -1;
return value;
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
}
outputDefined() {
// The output of image is determined by the list of available images in the evaluation context
return false;
}
}

View File

@@ -0,0 +1,81 @@
import {
BooleanType,
StringType,
ValueType,
NullType,
typeToString,
NumberType,
isValidType,
isValidNativeType
} from '../types';
import {RuntimeError} from '../runtime_error';
import {typeOf} from '../values';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class In implements Expression {
type: Type;
needle: Expression;
haystack: Expression;
constructor(needle: Expression, haystack: Expression) {
this.type = BooleanType;
this.needle = needle;
this.haystack = haystack;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 3) {
return context.error(
`Expected 2 arguments, but found ${args.length - 1} instead.`
) as null;
}
const needle = context.parse(args[1], 1, ValueType);
const haystack = context.parse(args[2], 2, ValueType);
if (!needle || !haystack) return null;
if (!isValidType(needle.type, [BooleanType, StringType, NumberType, NullType, ValueType])) {
return context.error(
`Expected first argument to be of type boolean, string, number or null, but found ${typeToString(needle.type)} instead`
) as null;
}
return new In(needle, haystack);
}
evaluate(ctx: EvaluationContext) {
const needle = this.needle.evaluate(ctx) as any;
const haystack = this.haystack.evaluate(ctx) as any;
if (!haystack) return false;
if (!isValidNativeType(needle, ['boolean', 'string', 'number', 'null'])) {
throw new RuntimeError(
`Expected first argument to be of type boolean, string, number or null, but found ${typeToString(typeOf(needle))} instead.`
);
}
if (!isValidNativeType(haystack, ['string', 'array'])) {
throw new RuntimeError(
`Expected second argument to be of type array or string, but found ${typeToString(typeOf(haystack))} instead.`
);
}
return haystack.indexOf(needle) >= 0;
}
eachChild(fn: (_: Expression) => void) {
fn(this.needle);
fn(this.haystack);
}
outputDefined() {
return true;
}
}

View File

@@ -0,0 +1,73 @@
import {Let} from './let';
import {Var} from './var';
import {Literal} from './literal';
import {Assertion} from './assertion';
import {Coercion} from './coercion';
import {At} from './at';
import {In} from './in';
import {IndexOf} from './index_of';
import {Match} from './match';
import {Case} from './case';
import {Slice} from './slice';
import {Step} from './step';
import {Interpolate} from './interpolate';
import {Coalesce} from './coalesce';
import {
Equals,
NotEquals,
LessThan,
GreaterThan,
LessThanOrEqual,
GreaterThanOrEqual
} from './comparison';
import {CollatorExpression} from './collator';
import {NumberFormat} from './number_format';
import {FormatExpression} from './format';
import {ImageExpression} from './image';
import {Length} from './length';
import {Within} from './within';
import {Distance} from './distance';
import {GlobalState} from './global_state';
import type {ExpressionRegistry} from '../expression';
export const expressions: ExpressionRegistry = {
// special forms
'==': Equals,
'!=': NotEquals,
'>': GreaterThan,
'<': LessThan,
'>=': GreaterThanOrEqual,
'<=': LessThanOrEqual,
array: Assertion,
at: At,
boolean: Assertion,
case: Case,
coalesce: Coalesce,
collator: CollatorExpression,
format: FormatExpression,
image: ImageExpression,
in: In,
'index-of': IndexOf,
interpolate: Interpolate,
'interpolate-hcl': Interpolate,
'interpolate-lab': Interpolate,
length: Length,
let: Let,
literal: Literal,
match: Match,
number: Assertion,
'number-format': NumberFormat,
object: Assertion,
slice: Slice,
step: Step,
string: Assertion,
'to-boolean': Coercion,
'to-color': Coercion,
'to-number': Coercion,
'to-string': Coercion,
var: Var,
within: Within,
distance: Distance,
'global-state': GlobalState
};

View File

@@ -0,0 +1,102 @@
import {
BooleanType,
StringType,
ValueType,
NullType,
typeToString,
NumberType,
isValidType,
isValidNativeType
} from '../types';
import {RuntimeError} from '../runtime_error';
import {typeOf} from '../values';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class IndexOf implements Expression {
type: Type;
needle: Expression;
haystack: Expression;
fromIndex: Expression;
constructor(needle: Expression, haystack: Expression, fromIndex?: Expression) {
this.type = NumberType;
this.needle = needle;
this.haystack = haystack;
this.fromIndex = fromIndex;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length <= 2 || args.length >= 5) {
return context.error(
`Expected 2 or 3 arguments, but found ${args.length - 1} instead.`
) as null;
}
const needle = context.parse(args[1], 1, ValueType);
const haystack = context.parse(args[2], 2, ValueType);
if (!needle || !haystack) return null;
if (!isValidType(needle.type, [BooleanType, StringType, NumberType, NullType, ValueType])) {
return context.error(
`Expected first argument to be of type boolean, string, number or null, but found ${typeToString(needle.type)} instead`
) as null;
}
if (args.length === 4) {
const fromIndex = context.parse(args[3], 3, NumberType);
if (!fromIndex) return null;
return new IndexOf(needle, haystack, fromIndex);
} else {
return new IndexOf(needle, haystack);
}
}
evaluate(ctx: EvaluationContext) {
const needle = this.needle.evaluate(ctx) as any;
const haystack = this.haystack.evaluate(ctx) as any;
if (!isValidNativeType(needle, ['boolean', 'string', 'number', 'null'])) {
throw new RuntimeError(
`Expected first argument to be of type boolean, string, number or null, but found ${typeToString(typeOf(needle))} instead.`
);
}
let fromIndex;
if (this.fromIndex) {
fromIndex = this.fromIndex.evaluate(ctx) as number;
}
if (isValidNativeType(haystack, ['string'])) {
const rawIndex = haystack.indexOf(needle, fromIndex);
if (rawIndex === -1) {
return -1;
} else {
// The index may be affected by surrogate pairs, so get the length of the preceding substring.
return [...haystack.slice(0, rawIndex)].length;
}
} else if (isValidNativeType(haystack, ['array'])) {
return haystack.indexOf(needle, fromIndex);
} else {
throw new RuntimeError(
`Expected second argument to be of type array or string, but found ${typeToString(typeOf(haystack))} instead.`
);
}
}
eachChild(fn: (_: Expression) => void) {
fn(this.needle);
fn(this.haystack);
if (this.fromIndex) {
fn(this.fromIndex);
}
}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,359 @@
import UnitBezier from '@mapbox/unitbezier';
import {
array,
ArrayType,
ColorType,
ColorTypeT,
NumberType,
NumberTypeT,
PaddingType,
PaddingTypeT,
NumberArrayTypeT,
ColorArrayTypeT,
VariableAnchorOffsetCollectionType,
VariableAnchorOffsetCollectionTypeT,
typeToString,
verifyType,
ProjectionDefinitionType,
ColorArrayType,
NumberArrayType
} from '../types';
import {findStopLessThanOrEqualTo} from '../stops';
import {Color} from '../types/color';
import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives';
import {Padding} from '../types/padding';
import {ColorArray} from '../types/color_array';
import {NumberArray} from '../types/number_array';
import {VariableAnchorOffsetCollection} from '../types/variable_anchor_offset_collection';
import {ProjectionDefinition} from '../types/projection_definition';
import type {Stops} from '../stops';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {ProjectionDefinitionTypeT, Type} from '../types';
export type InterpolationType =
| {
name: 'linear';
}
| {
name: 'exponential';
base: number;
}
| {
name: 'cubic-bezier';
controlPoints: [number, number, number, number];
};
type InterpolatedValueType =
| NumberTypeT
| ColorTypeT
| ProjectionDefinitionTypeT
| PaddingTypeT
| NumberArrayTypeT
| ColorArrayTypeT
| VariableAnchorOffsetCollectionTypeT
| ArrayType<NumberTypeT>;
export class Interpolate implements Expression {
type: InterpolatedValueType;
operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab';
interpolation: InterpolationType;
input: Expression;
labels: Array<number>;
outputs: Array<Expression>;
constructor(
type: InterpolatedValueType,
operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab',
interpolation: InterpolationType,
input: Expression,
stops: Stops
) {
this.type = type;
this.operator = operator;
this.interpolation = interpolation;
this.input = input;
this.labels = [];
this.outputs = [];
for (const [label, expression] of stops) {
this.labels.push(label);
this.outputs.push(expression);
}
}
static interpolationFactor(
interpolation: InterpolationType,
input: number,
lower: number,
upper: number
) {
let t = 0;
if (interpolation.name === 'exponential') {
t = exponentialInterpolation(input, interpolation.base, lower, upper);
} else if (interpolation.name === 'linear') {
t = exponentialInterpolation(input, 1, lower, upper);
} else if (interpolation.name === 'cubic-bezier') {
const c = interpolation.controlPoints;
const ub = new UnitBezier(c[0], c[1], c[2], c[3]);
t = ub.solve(exponentialInterpolation(input, 1, lower, upper));
}
return t;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
let [operator, interpolation, input, ...rest] = args;
if (!Array.isArray(interpolation) || interpolation.length === 0) {
return context.error('Expected an interpolation type expression.', 1) as null;
}
if (interpolation[0] === 'linear') {
interpolation = {name: 'linear'};
} else if (interpolation[0] === 'exponential') {
const base = interpolation[1];
if (typeof base !== 'number')
return context.error(
'Exponential interpolation requires a numeric base.',
1,
1
) as null;
interpolation = {
name: 'exponential',
base
};
} else if (interpolation[0] === 'cubic-bezier') {
const controlPoints = interpolation.slice(1);
if (
controlPoints.length !== 4 ||
controlPoints.some((t) => typeof t !== 'number' || t < 0 || t > 1)
) {
return context.error(
'Cubic bezier interpolation requires four numeric arguments with values between 0 and 1.',
1
) as null;
}
interpolation = {
name: 'cubic-bezier',
controlPoints: controlPoints as any
};
} else {
return context.error(
`Unknown interpolation type ${String(interpolation[0])}`,
1,
0
) as null;
}
if (args.length - 1 < 4) {
return context.error(
`Expected at least 4 arguments, but found only ${args.length - 1}.`
) as null;
}
if ((args.length - 1) % 2 !== 0) {
return context.error('Expected an even number of arguments.') as null;
}
input = context.parse(input, 2, NumberType);
if (!input) return null;
const stops: Stops = [];
let outputType: Type = null;
if (
(operator === 'interpolate-hcl' || operator === 'interpolate-lab') &&
context.expectedType != ColorArrayType
) {
outputType = ColorType;
} else if (context.expectedType && context.expectedType.kind !== 'value') {
outputType = context.expectedType;
}
for (let i = 0; i < rest.length; i += 2) {
const label = rest[i];
const value = rest[i + 1];
const labelKey = i + 3;
const valueKey = i + 4;
if (typeof label !== 'number') {
return context.error(
'Input/output pairs for "interpolate" expressions must be defined using literal numeric values (not computed expressions) for the input values.',
labelKey
) as null;
}
if (stops.length && stops[stops.length - 1][0] >= label) {
return context.error(
'Input/output pairs for "interpolate" expressions must be arranged with input values in strictly ascending order.',
labelKey
) as null;
}
const parsed = context.parse(value, valueKey, outputType);
if (!parsed) return null;
outputType = outputType || parsed.type;
stops.push([label, parsed]);
}
if (
!verifyType(outputType, NumberType) &&
!verifyType(outputType, ProjectionDefinitionType) &&
!verifyType(outputType, ColorType) &&
!verifyType(outputType, PaddingType) &&
!verifyType(outputType, NumberArrayType) &&
!verifyType(outputType, ColorArrayType) &&
!verifyType(outputType, VariableAnchorOffsetCollectionType) &&
!verifyType(outputType, array(NumberType))
) {
return context.error(`Type ${typeToString(outputType)} is not interpolatable.`) as null;
}
return new Interpolate(
outputType,
operator as any,
interpolation as InterpolationType,
input as Expression,
stops
);
}
evaluate(ctx: EvaluationContext) {
const labels = this.labels;
const outputs = this.outputs;
if (labels.length === 1) {
return outputs[0].evaluate(ctx);
}
const value: number = this.input.evaluate(ctx);
if (value <= labels[0]) {
return outputs[0].evaluate(ctx);
}
const stopCount = labels.length;
if (value >= labels[stopCount - 1]) {
return outputs[stopCount - 1].evaluate(ctx);
}
const index = findStopLessThanOrEqualTo(labels, value);
const lower = labels[index];
const upper = labels[index + 1];
const t = Interpolate.interpolationFactor(this.interpolation, value, lower, upper);
const outputLower = outputs[index].evaluate(ctx);
const outputUpper = outputs[index + 1].evaluate(ctx);
switch (this.operator) {
case 'interpolate':
switch (this.type.kind) {
case 'number':
return interpolateNumber(outputLower, outputUpper, t);
case 'color':
return Color.interpolate(outputLower, outputUpper, t);
case 'padding':
return Padding.interpolate(outputLower, outputUpper, t);
case 'colorArray':
return ColorArray.interpolate(outputLower, outputUpper, t);
case 'numberArray':
return NumberArray.interpolate(outputLower, outputUpper, t);
case 'variableAnchorOffsetCollection':
return VariableAnchorOffsetCollection.interpolate(
outputLower,
outputUpper,
t
);
case 'array':
return interpolateArray(outputLower, outputUpper, t);
case 'projectionDefinition':
return ProjectionDefinition.interpolate(outputLower, outputUpper, t);
}
case 'interpolate-hcl':
switch (this.type.kind) {
case 'color':
return Color.interpolate(outputLower, outputUpper, t, 'hcl');
case 'colorArray':
return ColorArray.interpolate(outputLower, outputUpper, t, 'hcl');
}
case 'interpolate-lab':
switch (this.type.kind) {
case 'color':
return Color.interpolate(outputLower, outputUpper, t, 'lab');
case 'colorArray':
return ColorArray.interpolate(outputLower, outputUpper, t, 'lab');
}
}
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
for (const expression of this.outputs) {
fn(expression);
}
}
outputDefined(): boolean {
return this.outputs.every((out) => out.outputDefined());
}
}
/**
* Returns a ratio that can be used to interpolate between exponential function
* stops.
* How it works: Two consecutive stop values define a (scaled and shifted) exponential function `f(x) = a * base^x + b`, where `base` is the user-specified base,
* and `a` and `b` are constants affording sufficient degrees of freedom to fit
* the function to the given stops.
*
* Here's a bit of algebra that lets us compute `f(x)` directly from the stop
* values without explicitly solving for `a` and `b`:
*
* First stop value: `f(x0) = y0 = a * base^x0 + b`
* Second stop value: `f(x1) = y1 = a * base^x1 + b`
* => `y1 - y0 = a(base^x1 - base^x0)`
* => `a = (y1 - y0)/(base^x1 - base^x0)`
*
* Desired value: `f(x) = y = a * base^x + b`
* => `f(x) = y0 + a * (base^x - base^x0)`
*
* From the above, we can replace the `a` in `a * (base^x - base^x0)` and do a
* little algebra:
* ```
* a * (base^x - base^x0) = (y1 - y0)/(base^x1 - base^x0) * (base^x - base^x0)
* = (y1 - y0) * (base^x - base^x0) / (base^x1 - base^x0)
* ```
*
* If we let `(base^x - base^x0) / (base^x1 base^x0)`, then we have
* `f(x) = y0 + (y1 - y0) * ratio`. In other words, `ratio` may be treated as
* an interpolation factor between the two stops' output values.
*
* (Note: a slightly different form for `ratio`,
* `(base^(x-x0) - 1) / (base^(x1-x0) - 1) `, is equivalent, but requires fewer
* expensive `Math.pow()` operations.)
*
* @private
*/
function exponentialInterpolation(input, base, lowerValue, upperValue) {
const difference = upperValue - lowerValue;
const progress = input - lowerValue;
if (difference === 0) {
return 0;
} else if (base === 1) {
return progress / difference;
} else {
return (Math.pow(base, progress) - 1) / (Math.pow(base, difference) - 1);
}
}
export const interpolateFactory = {
color: Color.interpolate,
number: interpolateNumber,
padding: Padding.interpolate,
numberArray: NumberArray.interpolate,
colorArray: ColorArray.interpolate,
variableAnchorOffsetCollection: VariableAnchorOffsetCollection.interpolate,
array: interpolateArray
};

View File

@@ -0,0 +1,62 @@
import {NumberType, typeToString} from '../types';
import {typeOf} from '../values';
import {RuntimeError} from '../runtime_error';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class Length implements Expression {
type: Type;
input: Expression;
constructor(input: Expression) {
this.type = NumberType;
this.input = input;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2)
return context.error(
`Expected 1 argument, but found ${args.length - 1} instead.`
) as null;
const input = context.parse(args[1], 1);
if (!input) return null;
if (
input.type.kind !== 'array' &&
input.type.kind !== 'string' &&
input.type.kind !== 'value'
)
return context.error(
`Expected argument of type string or array, but found ${typeToString(input.type)} instead.`
) as null;
return new Length(input);
}
evaluate(ctx: EvaluationContext) {
const input = this.input.evaluate(ctx);
if (typeof input === 'string') {
// The length may be affected by surrogate pairs.
return [...input].length;
} else if (Array.isArray(input)) {
return input.length;
} else {
throw new RuntimeError(
`Expected value to be of type string or array, but found ${typeToString(typeOf(input))} instead.`
);
}
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,72 @@
import type {Type} from '../types';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
export class Let implements Expression {
type: Type;
bindings: Array<[string, Expression]>;
result: Expression;
constructor(bindings: Array<[string, Expression]>, result: Expression) {
this.type = result.type;
this.bindings = [].concat(bindings);
this.result = result;
}
evaluate(ctx: EvaluationContext) {
return this.result.evaluate(ctx);
}
eachChild(fn: (_: Expression) => void) {
for (const binding of this.bindings) {
fn(binding[1]);
}
fn(this.result);
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 4)
return context.error(
`Expected at least 3 arguments, but found ${args.length - 1} instead.`
) as null;
const bindings: Array<[string, Expression]> = [];
for (let i = 1; i < args.length - 1; i += 2) {
const name = args[i];
if (typeof name !== 'string') {
return context.error(
`Expected string, but found ${typeof name} instead.`,
i
) as null;
}
if (/[^a-zA-Z0-9_]/.test(name)) {
return context.error(
"Variable names must contain only alphanumeric characters or '_'.",
i
) as null;
}
const value = context.parse(args[i + 1], i + 1);
if (!value) return null;
bindings.push([name, value]);
}
const result = context.parse(
args[args.length - 1],
args.length - 1,
context.expectedType,
bindings
);
if (!result) return null;
return new Let(bindings, result);
}
outputDefined() {
return this.result.outputDefined();
}
}

View File

@@ -0,0 +1,52 @@
import {isValue, typeOf} from '../values';
import type {Type} from '../types';
import type {Value} from '../values';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
export class Literal implements Expression {
type: Type;
value: Value;
constructor(type: Type, value: Value) {
this.type = type;
this.value = value;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2)
return context.error(
`'literal' expression requires exactly one argument, but found ${args.length - 1} instead.`
) as null;
if (!isValue(args[1])) return context.error('invalid value') as null;
const value = args[1] as any;
let type = typeOf(value);
// special case: infer the item type if possible for zero-length arrays
const expected = context.expectedType;
if (
type.kind === 'array' &&
type.N === 0 &&
expected &&
expected.kind === 'array' &&
(typeof expected.N !== 'number' || expected.N === 0)
) {
type = expected;
}
return new Literal(type, value);
}
evaluate() {
return this.value;
}
eachChild() {}
outputDefined() {
return true;
}
}

View File

@@ -0,0 +1,130 @@
import {typeOf} from '../values';
import {ValueType} from '../types';
import type {Type} from '../types';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
// Map input label values to output expression index
type Cases = {
[k in number | string]: number;
};
export class Match implements Expression {
type: Type;
inputType: Type;
input: Expression;
cases: Cases;
outputs: Array<Expression>;
otherwise: Expression;
constructor(
inputType: Type,
outputType: Type,
input: Expression,
cases: Cases,
outputs: Array<Expression>,
otherwise: Expression
) {
this.inputType = inputType;
this.type = outputType;
this.input = input;
this.cases = cases;
this.outputs = outputs;
this.otherwise = otherwise;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length < 5)
return context.error(
`Expected at least 4 arguments, but found only ${args.length - 1}.`
) as null;
if (args.length % 2 !== 1)
return context.error('Expected an even number of arguments.') as null;
let inputType;
let outputType;
if (context.expectedType && context.expectedType.kind !== 'value') {
outputType = context.expectedType;
}
const cases = {};
const outputs = [];
for (let i = 2; i < args.length - 1; i += 2) {
let labels = args[i] as unknown[];
const value = args[i + 1];
if (!Array.isArray(labels)) {
labels = [labels];
}
const labelContext = context.concat(i);
if (labels.length === 0) {
return labelContext.error('Expected at least one branch label.') as null;
}
for (const label of labels) {
if (typeof label !== 'number' && typeof label !== 'string') {
return labelContext.error('Branch labels must be numbers or strings.') as null;
} else if (typeof label === 'number' && Math.abs(label) > Number.MAX_SAFE_INTEGER) {
return labelContext.error(
`Branch labels must be integers no larger than ${Number.MAX_SAFE_INTEGER}.`
) as null;
} else if (typeof label === 'number' && Math.floor(label) !== label) {
return labelContext.error(
'Numeric branch labels must be integer values.'
) as null;
} else if (!inputType) {
inputType = typeOf(label);
} else if (labelContext.checkSubtype(inputType, typeOf(label))) {
return null;
}
if (typeof cases[String(label)] !== 'undefined') {
return labelContext.error('Branch labels must be unique.') as null;
}
cases[String(label)] = outputs.length;
}
const result = context.parse(value, i, outputType);
if (!result) return null;
outputType = outputType || result.type;
outputs.push(result);
}
const input = context.parse(args[1], 1, ValueType);
if (!input) return null;
const otherwise = context.parse(args[args.length - 1], args.length - 1, outputType);
if (!otherwise) return null;
if (
input.type.kind !== 'value' &&
context.concat(1).checkSubtype(inputType as any, input.type)
) {
return null;
}
return new Match(inputType as any, outputType as any, input, cases, outputs, otherwise);
}
evaluate(ctx: EvaluationContext) {
const input = this.input.evaluate(ctx) as any;
const output =
(typeOf(input) === this.inputType && this.outputs[this.cases[input]]) || this.otherwise;
return output.evaluate(ctx);
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
this.outputs.forEach(fn);
fn(this.otherwise);
}
outputDefined(): boolean {
return this.outputs.every((out) => out.outputDefined()) && this.otherwise.outputDefined();
}
}

View File

@@ -0,0 +1,141 @@
import {StringType, NumberType} from '../types';
import type {Expression} from '../expression';
import type {EvaluationContext} from '../evaluation_context';
import type {ParsingContext} from '../parsing_context';
import type {Type} from '../types';
export class NumberFormat implements Expression {
type: Type;
number: Expression;
/**
* BCP 47 language tag
*/
locale: Expression | null;
/**
* ISO 4217 currency code, required if style=currency
*/
currency: Expression | null;
/**
* CLDR or ECMA-402 unit specifier, required if style=unit
*/
unit: Expression | null;
/**
* @default 0
*/
minFractionDigits: Expression | null;
/**
* @default 3
*/
maxFractionDigits: Expression | null;
constructor(
number: Expression,
locale: Expression | null,
currency: Expression | null,
unit: Expression | null,
minFractionDigits: Expression | null,
maxFractionDigits: Expression | null
) {
this.type = StringType;
this.number = number;
this.locale = locale;
this.currency = currency;
this.unit = unit;
this.minFractionDigits = minFractionDigits;
this.maxFractionDigits = maxFractionDigits;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 3) return context.error('Expected two arguments.') as null;
const number = context.parse(args[1], 1, NumberType);
if (!number) return null;
const options = args[2] as any;
if (typeof options !== 'object' || Array.isArray(options))
return context.error('NumberFormat options argument must be an object.') as null;
let locale = null;
if (options['locale']) {
locale = context.parse(options['locale'], 1, StringType);
if (!locale) return null;
}
let currency = null;
if (options['currency']) {
currency = context.parse(options['currency'], 1, StringType);
if (!currency) return null;
}
let unit = null;
if (options['unit']) {
unit = context.parse(options['unit'], 1, StringType);
if (!unit) return null;
}
if (currency && unit) {
return context.error(
'NumberFormat options `currency` and `unit` are mutually exclusive'
) as null;
}
let minFractionDigits = null;
if (options['min-fraction-digits']) {
minFractionDigits = context.parse(options['min-fraction-digits'], 1, NumberType);
if (!minFractionDigits) return null;
}
let maxFractionDigits = null;
if (options['max-fraction-digits']) {
maxFractionDigits = context.parse(options['max-fraction-digits'], 1, NumberType);
if (!maxFractionDigits) return null;
}
return new NumberFormat(
number,
locale,
currency,
unit,
minFractionDigits,
maxFractionDigits
);
}
evaluate(ctx: EvaluationContext) {
return new Intl.NumberFormat(this.locale ? this.locale.evaluate(ctx) : [], {
style: this.currency ? 'currency' : this.unit ? 'unit' : 'decimal',
currency: this.currency ? this.currency.evaluate(ctx) : undefined,
unit: this.unit ? this.unit.evaluate(ctx) : undefined,
minimumFractionDigits: this.minFractionDigits
? this.minFractionDigits.evaluate(ctx)
: undefined,
maximumFractionDigits: this.maxFractionDigits
? this.maxFractionDigits.evaluate(ctx)
: undefined
}).format(this.number.evaluate(ctx));
}
eachChild(fn: (_: Expression) => void) {
fn(this.number);
if (this.locale) {
fn(this.locale);
}
if (this.currency) {
fn(this.currency);
}
if (this.unit) {
fn(this.unit);
}
if (this.minFractionDigits) {
fn(this.minFractionDigits);
}
if (this.maxFractionDigits) {
fn(this.maxFractionDigits);
}
}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,90 @@
import {
ValueType,
NumberType,
StringType,
array,
typeToString,
isValidType,
isValidNativeType
} from '../types';
import {RuntimeError} from '../runtime_error';
import {typeOf} from '../values';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class Slice implements Expression {
type: Type;
input: Expression;
beginIndex: Expression;
endIndex: Expression;
constructor(type: Type, input: Expression, beginIndex: Expression, endIndex?: Expression) {
this.type = type;
this.input = input;
this.beginIndex = beginIndex;
this.endIndex = endIndex;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length <= 2 || args.length >= 5) {
return context.error(
`Expected 2 or 3 arguments, but found ${args.length - 1} instead.`
) as null;
}
const input = context.parse(args[1], 1, ValueType);
const beginIndex = context.parse(args[2], 2, NumberType);
if (!input || !beginIndex) return null;
if (!isValidType(input.type, [array(ValueType), StringType, ValueType])) {
return context.error(
`Expected first argument to be of type array or string, but found ${typeToString(input.type)} instead`
) as null;
}
if (args.length === 4) {
const endIndex = context.parse(args[3], 3, NumberType);
if (!endIndex) return null;
return new Slice(input.type, input, beginIndex, endIndex);
} else {
return new Slice(input.type, input, beginIndex);
}
}
evaluate(ctx: EvaluationContext) {
const input = this.input.evaluate(ctx) as any;
const beginIndex = this.beginIndex.evaluate(ctx) as number;
let endIndex;
if (this.endIndex) {
endIndex = this.endIndex.evaluate(ctx) as number;
}
if (isValidNativeType(input, ['string'])) {
// Indices may be affected by surrogate pairs.
return [...input].slice(beginIndex, endIndex).join('');
} else if (isValidNativeType(input, ['array'])) {
return input.slice(beginIndex, endIndex);
} else {
throw new RuntimeError(
`Expected first argument to be of type array or string, but found ${typeToString(typeOf(input))} instead.`
);
}
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
fn(this.beginIndex);
if (this.endIndex) {
fn(this.endIndex);
}
}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,113 @@
import {NumberType} from '../types';
import {findStopLessThanOrEqualTo} from '../stops';
import type {Stops} from '../stops';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {Type} from '../types';
export class Step implements Expression {
type: Type;
input: Expression;
labels: Array<number>;
outputs: Array<Expression>;
constructor(type: Type, input: Expression, stops: Stops) {
this.type = type;
this.input = input;
this.labels = [];
this.outputs = [];
for (const [label, expression] of stops) {
this.labels.push(label);
this.outputs.push(expression);
}
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length - 1 < 4) {
return context.error(
`Expected at least 4 arguments, but found only ${args.length - 1}.`
) as null;
}
if ((args.length - 1) % 2 !== 0) {
return context.error('Expected an even number of arguments.') as null;
}
const input = context.parse(args[1], 1, NumberType);
if (!input) return null;
const stops: Stops = [];
let outputType: Type = null;
if (context.expectedType && context.expectedType.kind !== 'value') {
outputType = context.expectedType;
}
for (let i = 1; i < args.length; i += 2) {
const label = i === 1 ? -Infinity : args[i];
const value = args[i + 1];
const labelKey = i;
const valueKey = i + 1;
if (typeof label !== 'number') {
return context.error(
'Input/output pairs for "step" expressions must be defined using literal numeric values (not computed expressions) for the input values.',
labelKey
) as null;
}
if (stops.length && stops[stops.length - 1][0] >= label) {
return context.error(
'Input/output pairs for "step" expressions must be arranged with input values in strictly ascending order.',
labelKey
) as null;
}
const parsed = context.parse(value, valueKey, outputType);
if (!parsed) return null;
outputType = outputType || parsed.type;
stops.push([label, parsed]);
}
return new Step(outputType, input, stops);
}
evaluate(ctx: EvaluationContext) {
const labels = this.labels;
const outputs = this.outputs;
if (labels.length === 1) {
return outputs[0].evaluate(ctx);
}
const value = this.input.evaluate(ctx) as any as number;
if (value <= labels[0]) {
return outputs[0].evaluate(ctx);
}
const stopCount = labels.length;
if (value >= labels[stopCount - 1]) {
return outputs[stopCount - 1].evaluate(ctx);
}
const index = findStopLessThanOrEqualTo(labels, value);
return outputs[index].evaluate(ctx);
}
eachChild(fn: (_: Expression) => void) {
fn(this.input);
for (const expression of this.outputs) {
fn(expression);
}
}
outputDefined(): boolean {
return this.outputs.every((out) => out.outputDefined());
}
}

View File

@@ -0,0 +1,43 @@
import type {Type} from '../types';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
export class Var implements Expression {
type: Type;
name: string;
boundExpression: Expression;
constructor(name: string, boundExpression: Expression) {
this.type = boundExpression.type;
this.name = name;
this.boundExpression = boundExpression;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2 || typeof args[1] !== 'string')
return context.error(
"'var' expression requires exactly one string literal argument."
) as null;
const name = args[1];
if (!context.scope.has(name)) {
return context.error(
`Unknown variable "${name}". Make sure "${name}" has been bound in an enclosing "let" expression before using it.`,
1
) as null;
}
return new Var(name, context.scope.get(name));
}
evaluate(ctx: EvaluationContext) {
return this.boundExpression.evaluate(ctx);
}
eachChild() {}
outputDefined() {
return false;
}
}

View File

@@ -0,0 +1,250 @@
import {isValue} from '../values';
import type {Type} from '../types';
import {BooleanType} from '../types';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import {ICanonicalTileID} from '../../tiles_and_coordinates';
import {
BBox,
EXTENT,
boxWithinBox,
getTileCoordinates,
lineStringWithinPolygon,
lineStringWithinPolygons,
pointWithinPolygon,
pointWithinPolygons,
updateBBox
} from '../../util/geometry_util';
import {Point2D} from '../../point2d';
type GeoJSONPolygons = GeoJSON.Polygon | GeoJSON.MultiPolygon;
function getTilePolygon(
coordinates: GeoJSON.Position[][],
bbox: BBox,
canonical: ICanonicalTileID
) {
const polygon = [];
for (let i = 0; i < coordinates.length; i++) {
const ring = [];
for (let j = 0; j < coordinates[i].length; j++) {
const coord = getTileCoordinates(coordinates[i][j], canonical);
updateBBox(bbox, coord);
ring.push(coord);
}
polygon.push(ring);
}
return polygon;
}
function getTilePolygons(
coordinates: GeoJSON.Position[][][],
bbox: BBox,
canonical: ICanonicalTileID
) {
const polygons = [];
for (let i = 0; i < coordinates.length; i++) {
const polygon = getTilePolygon(coordinates[i], bbox, canonical);
polygons.push(polygon);
}
return polygons;
}
function updatePoint(p: GeoJSON.Position, bbox: BBox, polyBBox: BBox, worldSize: number) {
if (p[0] < polyBBox[0] || p[0] > polyBBox[2]) {
const halfWorldSize = worldSize * 0.5;
let shift =
p[0] - polyBBox[0] > halfWorldSize
? -worldSize
: polyBBox[0] - p[0] > halfWorldSize
? worldSize
: 0;
if (shift === 0) {
shift =
p[0] - polyBBox[2] > halfWorldSize
? -worldSize
: polyBBox[2] - p[0] > halfWorldSize
? worldSize
: 0;
}
p[0] += shift;
}
updateBBox(bbox, p);
}
function resetBBox(bbox: BBox) {
bbox[0] = bbox[1] = Infinity;
bbox[2] = bbox[3] = -Infinity;
}
function getTilePoints(
geometry: Point2D[][],
pointBBox: BBox,
polyBBox: BBox,
canonical: ICanonicalTileID
): [number, number][] {
const worldSize = Math.pow(2, canonical.z) * EXTENT;
const shifts = [canonical.x * EXTENT, canonical.y * EXTENT];
const tilePoints: [number, number][] = [];
for (const points of geometry) {
for (const point of points) {
const p: [number, number] = [point.x + shifts[0], point.y + shifts[1]];
updatePoint(p, pointBBox, polyBBox, worldSize);
tilePoints.push(p);
}
}
return tilePoints;
}
function getTileLines(
geometry: Point2D[][],
lineBBox: BBox,
polyBBox: BBox,
canonical: ICanonicalTileID
): [number, number][][] {
const worldSize = Math.pow(2, canonical.z) * EXTENT;
const shifts = [canonical.x * EXTENT, canonical.y * EXTENT];
const tileLines: [number, number][][] = [];
for (const line of geometry) {
const tileLine: [number, number][] = [];
for (const point of line) {
const p: [number, number] = [point.x + shifts[0], point.y + shifts[1]];
updateBBox(lineBBox, p);
tileLine.push(p);
}
tileLines.push(tileLine);
}
if (lineBBox[2] - lineBBox[0] <= worldSize / 2) {
resetBBox(lineBBox);
for (const line of tileLines) {
for (const p of line) {
updatePoint(p, lineBBox, polyBBox, worldSize);
}
}
}
return tileLines;
}
function pointsWithinPolygons(ctx: EvaluationContext, polygonGeometry: GeoJSONPolygons) {
const pointBBox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
const polyBBox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
const canonical = ctx.canonicalID();
if (polygonGeometry.type === 'Polygon') {
const tilePolygon = getTilePolygon(polygonGeometry.coordinates, polyBBox, canonical);
const tilePoints = getTilePoints(ctx.geometry(), pointBBox, polyBBox, canonical);
if (!boxWithinBox(pointBBox, polyBBox)) return false;
for (const point of tilePoints) {
if (!pointWithinPolygon(point, tilePolygon)) return false;
}
}
if (polygonGeometry.type === 'MultiPolygon') {
const tilePolygons = getTilePolygons(polygonGeometry.coordinates, polyBBox, canonical);
const tilePoints = getTilePoints(ctx.geometry(), pointBBox, polyBBox, canonical);
if (!boxWithinBox(pointBBox, polyBBox)) return false;
for (const point of tilePoints) {
if (!pointWithinPolygons(point, tilePolygons)) return false;
}
}
return true;
}
function linesWithinPolygons(ctx: EvaluationContext, polygonGeometry: GeoJSONPolygons) {
const lineBBox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
const polyBBox: BBox = [Infinity, Infinity, -Infinity, -Infinity];
const canonical = ctx.canonicalID();
if (polygonGeometry.type === 'Polygon') {
const tilePolygon = getTilePolygon(polygonGeometry.coordinates, polyBBox, canonical);
const tileLines = getTileLines(ctx.geometry(), lineBBox, polyBBox, canonical);
if (!boxWithinBox(lineBBox, polyBBox)) return false;
for (const line of tileLines) {
if (!lineStringWithinPolygon(line, tilePolygon)) return false;
}
}
if (polygonGeometry.type === 'MultiPolygon') {
const tilePolygons = getTilePolygons(polygonGeometry.coordinates, polyBBox, canonical);
const tileLines = getTileLines(ctx.geometry(), lineBBox, polyBBox, canonical);
if (!boxWithinBox(lineBBox, polyBBox)) return false;
for (const line of tileLines) {
if (!lineStringWithinPolygons(line, tilePolygons)) return false;
}
}
return true;
}
export class Within implements Expression {
type: Type;
geojson: GeoJSON.GeoJSON;
geometries: GeoJSONPolygons;
constructor(geojson: GeoJSON.GeoJSON, geometries: GeoJSONPolygons) {
this.type = BooleanType;
this.geojson = geojson;
this.geometries = geometries;
}
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
if (args.length !== 2)
return context.error(
`'within' expression requires exactly one argument, but found ${args.length - 1} instead.`
) as null;
if (isValue(args[1])) {
const geojson = args[1] as any;
if (geojson.type === 'FeatureCollection') {
const polygonsCoords: GeoJSON.Position[][][] = [];
for (const polygon of geojson.features) {
const {type, coordinates} = polygon.geometry;
if (type === 'Polygon') {
polygonsCoords.push(coordinates);
}
if (type === 'MultiPolygon') {
polygonsCoords.push(...coordinates);
}
}
if (polygonsCoords.length) {
const multipolygonWrapper: GeoJSON.MultiPolygon = {
type: 'MultiPolygon',
coordinates: polygonsCoords
};
return new Within(geojson, multipolygonWrapper);
}
} else if (geojson.type === 'Feature') {
const type = geojson.geometry.type;
if (type === 'Polygon' || type === 'MultiPolygon') {
return new Within(geojson, geojson.geometry);
}
} else if (geojson.type === 'Polygon' || geojson.type === 'MultiPolygon') {
return new Within(geojson, geojson);
}
}
return context.error(
"'within' expression requires valid geojson object that contains polygon geometry type."
) as null;
}
evaluate(ctx: EvaluationContext) {
if (ctx.geometry() != null && ctx.canonicalID() != null) {
if (ctx.geometryType() === 'Point') {
return pointsWithinPolygons(ctx, this.geometries);
} else if (ctx.geometryType() === 'LineString') {
return linesWithinPolygons(ctx, this.geometries);
}
}
return false;
}
eachChild() {}
outputDefined(): boolean {
return true;
}
}

View File

@@ -0,0 +1,60 @@
import type {FormattedSection} from './types/formatted';
import type {GlobalProperties, Feature, FeatureState} from './index';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {Color} from './types/color';
const geometryTypes = ['Unknown', 'Point', 'LineString', 'Polygon'];
export class EvaluationContext {
globals: GlobalProperties;
feature: Feature;
featureState: FeatureState;
formattedSection: FormattedSection;
availableImages: Array<string>;
canonical: ICanonicalTileID;
_parseColorCache: Map<string, Color>;
constructor() {
this.globals = null;
this.feature = null;
this.featureState = null;
this.formattedSection = null;
this._parseColorCache = new Map<string, Color>();
this.availableImages = null;
this.canonical = null;
}
id() {
return this.feature && 'id' in this.feature ? this.feature.id : null;
}
geometryType() {
return this.feature
? typeof this.feature.type === 'number'
? geometryTypes[this.feature.type]
: this.feature.type
: null;
}
geometry() {
return this.feature && 'geometry' in this.feature ? this.feature.geometry : null;
}
canonicalID() {
return this.canonical;
}
properties() {
return (this.feature && this.feature.properties) || {};
}
parseColor(input: string): Color {
let cached = this._parseColorCache.get(input);
if (!cached) {
cached = Color.parse(input);
this._parseColorCache.set(input, cached);
}
return cached;
}
}

View File

@@ -0,0 +1,910 @@
import {describe, expectTypeOf, test} from 'vitest';
import type {ExpressionInputType, ExpressionSpecification} from '../types.g';
describe('Distance expression', () => {
describe('Invalid expression', () => {
test('missing geometry typecheck', () => {
expectTypeOf<['distance']>().not.toExtend<ExpressionSpecification>();
});
test('invalid geometry typecheck', () => {
expectTypeOf<['distance', {type: 'Nope!'}]>().not.toExtend<ExpressionSpecification>();
});
test('expression as geometry typecheck', () => {
expectTypeOf<
['distance', ['literal', {type: 'MultiPoint'; coordinates: [[3, 3], [3, 4]]}]]
>().not.toExtend<ExpressionSpecification>();
});
});
describe('valid expression', () => {
test('multi point geometry typecheck', () => {
expectTypeOf<
['distance', {type: 'MultiPoint'; coordinates: [[3, 3], [3, 4]]}]
>().toExtend<ExpressionSpecification>();
});
test('multi line geometry typecheck', () => {
expectTypeOf<
['distance', {type: 'MultiLineString'; coordinates: [[[3, 3], [3, 4]]]}]
>().toExtend<ExpressionSpecification>();
});
test('multi polygon geometry typecheck', () => {
expectTypeOf<
[
'distance',
{
type: 'MultiPolygon';
coordinates: [[[[3, 3], [3, 4], [4, 4], [4, 3], [3, 3]]]];
}
]
>().toExtend<ExpressionSpecification>();
});
});
});
describe('"array" expression', () => {
test('type requires an expression as the input value', () => {
expectTypeOf<['array', 1, 2, 3]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', [1, 2, 3]]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'number', [1, 2, 3]]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'number', 3, [1, 2, 3]]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', ['literal', []]]>().toExtend<ExpressionSpecification>();
expectTypeOf<
['array', 'number', ['literal', [1, 2, 3]]]
>().toExtend<ExpressionSpecification>();
expectTypeOf<
['array', 'number', typeof Number.MAX_SAFE_INTEGER, ['get', 'arr']]
>().toExtend<ExpressionSpecification>();
});
test('type requires either "string", "number", or "boolean" as the asserted type', () => {
expectTypeOf<['array', 0, ['literal', []]]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', '0', ['literal', []]]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['array', ['literal', 'number'], ['literal', []]]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'string', ['literal', []]]>().toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'number', ['literal', []]]>().toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'boolean', ['literal', []]]>().toExtend<ExpressionSpecification>();
});
test('type requires a number literal as the asserted length', () => {
expectTypeOf<
['array', 'string', '0', ['literal', []]]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['array', 'string', ['literal', 0], ['literal', []]]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['array', 'string', 0, ['literal', []]]>().toExtend<ExpressionSpecification>();
expectTypeOf<
['array', 'string', 2, ['literal', ['one', 'two']]]
>().toExtend<ExpressionSpecification>();
});
});
describe('"format" expression', () => {
test('type rejects bare string arrays in the "text-font" style override', () => {
expectTypeOf<
['format', 'foo', {'text-font': ['Helvetica', 'Arial']}]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which scales text', () => {
expectTypeOf<
['format', ['get', 'title'], {'font-scale': 0.8}]
>().toExtend<ExpressionSpecification>();
});
test('type requires either "bottom", "center", or "top" as the vertical alignment', () => {
expectTypeOf<
['format', 'foo', {'vertical-align': 'middle'}]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which aligns a text section vertically', () => {
expectTypeOf<
['format', 'foo', {'vertical-align': 'top'}]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which aligns an image vertically', () => {
expectTypeOf<
['format', ['image', 'bar'], {'vertical-align': 'bottom'}]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which applies multiple style overrides', () => {
expectTypeOf<
['format', 'foo', {'font-scale': 0.8; 'text-color': '#fff'}]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which applies default styles with an empty overrides object', () => {
expectTypeOf<['format', ['downcase', 'BaR'], {}]>().toExtend<ExpressionSpecification>();
});
});
describe('"image" expression', () => {
test('type requires a string as the image name argument', () => {
expectTypeOf<['image', true]>().not.toExtend<ExpressionSpecification>();
expectTypeOf<['image', 123]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which returns an image with a string literal as the image name', () => {
expectTypeOf<['image', 'foo']>().toExtend<ExpressionSpecification>();
});
test('type accepts an expression which returns an image with an expression as the image name', () => {
expectTypeOf<['image', ['concat', 'foo', 'bar']]>().toExtend<ExpressionSpecification>();
});
});
describe('"typeof" expression', () => {
test('type requires a value argument', () => {
expectTypeOf<['typeof']>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a second argument', () => {
expectTypeOf<['typeof', true, 42]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which returns a string describing the type of the given literal value', () => {
expectTypeOf<['typeof', true]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which returns a string describing the type of the given expression value', () => {
expectTypeOf<
['typeof', ['concat', 'foo', ['to-string', 0]]]
>().toExtend<ExpressionSpecification>();
});
});
describe('"feature-state" expression', () => {
test('type accepts expression which retrieves the feature state with a string literal argument', () => {
expectTypeOf<['feature-state', 'foo']>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which retrieves the feature state with an expression argument', () => {
expectTypeOf<['feature-state', ['get', 'feat-prop']]>().toExtend<ExpressionSpecification>();
});
});
describe('"get" expression', () => {
test('type requires an expression as the object argument if provided', () => {
expectTypeOf<['get', 'prop', {prop: 4}]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which retrieves a property value from the given object argument', () => {
expectTypeOf<['get', 'prop', ['literal', {prop: 4}]]>().toExtend<ExpressionSpecification>();
});
});
describe('"global-state" expression', () => {
test('type requires a property argument', () => {
expectTypeOf<['global-state']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string literal as the property argument', () => {
expectTypeOf<
['global-state', ['concat', 'pr', 'op']]
>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a second argument', () => {
expectTypeOf<['global-state', 'foo', 'bar']>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which evaluates a global state property', () => {
expectTypeOf<['global-state', 'foo']>().toExtend<ExpressionSpecification>();
});
});
describe('"has" expression', () => {
test('type requires an expression as the object argument if provided', () => {
expectTypeOf<['has', 'prop', {prop: 4}]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which checks whether a property exists in the given object argument', () => {
expectTypeOf<['has', 'prop', ['literal', {prop: 4}]]>().toExtend<ExpressionSpecification>();
});
});
describe('"at" expression', () => {
test('type accepts expression which retrieves the item at the specified index in the given array', () => {
expectTypeOf<['at', 2, ['literal', [1, 2, 3]]]>().toExtend<ExpressionSpecification>();
});
});
describe('"in" expression', () => {
test('type requires a needle', () => {
expectTypeOf<['in']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a haystack', () => {
expectTypeOf<['in', 'a']>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a third argument', () => {
expectTypeOf<['in', 'a', 'abc', 1]>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string or array as the haystack', () => {
expectTypeOf<['in', 't', true]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a substring in a string', () => {
expectTypeOf<['in', 'b', 'abc']>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a non-literal substring in a string', () => {
expectTypeOf<
['in', ['downcase', 'C'], ['concat', 'ab', 'cd']]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds an element in an array', () => {
expectTypeOf<['in', 2, ['literal', [1, 2, 3]]]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a non-literal element in an array', () => {
expectTypeOf<
['in', ['*', 2, 5], ['literal', [1, 10, 100]]]
>().toExtend<ExpressionSpecification>();
});
});
describe('"index-of" expression', () => {
test('type requires a needle', () => {
expectTypeOf<['index-of']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a haystack', () => {
expectTypeOf<['index-of', 'a']>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a fourth argument', () => {
expectTypeOf<['index-of', 'a', 'abc', 1, 8]>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string or array as the haystack', () => {
expectTypeOf<['index-of', 't', true]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a substring in a string', () => {
expectTypeOf<['index-of', 'b', 'abc']>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a non-literal substring in a string', () => {
expectTypeOf<
['index-of', ['downcase', 'C'], ['concat', 'ab', 'cd']]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which starts looking for the substring at a start index', () => {
expectTypeOf<['index-of', 'a', 'abc', 1]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which starts looking for the substring at a non-literal start index', () => {
expectTypeOf<['index-of', 'c', 'abc', ['-', 0, 1]]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds an element in an array', () => {
expectTypeOf<['index-of', 2, ['literal', [1, 2, 3]]]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which finds a non-literal element in an array', () => {
expectTypeOf<
['index-of', ['*', 2, 5], ['literal', [1, 10, 100]]]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which starts looking for the element at a start index', () => {
expectTypeOf<
['index-of', 1, ['literal', [1, 2, 3]], 1]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which starts looking for the element at a non-literal start index', () => {
expectTypeOf<
['index-of', 2, ['literal', [1, 2, 3]], ['+', 0, -1, 2]]
>().toExtend<ExpressionSpecification>();
});
});
describe('"length" expression', () => {
test('type requires an argument', () => {
expectTypeOf<['length']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string or array as the argument', () => {
expectTypeOf<['length', true]>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a second argument', () => {
expectTypeOf<['length', 'abc', 'def']>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which measures a string', () => {
expectTypeOf<['length', 'abc']>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which measures an array', () => {
expectTypeOf<['length', ['literal', [1, 2, 3]]]>().toExtend<ExpressionSpecification>();
});
});
describe('"slice" expression', () => {
test('type requires an input argument', () => {
expectTypeOf<['slice']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a start index argument', () => {
expectTypeOf<['slice', 'abc']>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a fourth argument', () => {
expectTypeOf<['slice', 'abc', 0, 1, 8]>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string or array as the input argument', () => {
expectTypeOf<['slice', true, 0]>().not.toExtend<ExpressionSpecification>();
});
test('type requires a number as the start index argument', () => {
expectTypeOf<['slice', 'abc', true]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which slices a string', () => {
expectTypeOf<['slice', 'abc', 1]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which slices a string by a given range', () => {
expectTypeOf<['slice', 'abc', 1, 1]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which slices an array', () => {
expectTypeOf<['slice', ['literal', [1, 2, 3]], 1]>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which slices an array by a given range', () => {
expectTypeOf<['slice', ['literal', [1, 2, 3]], 1, 1]>().toExtend<ExpressionSpecification>();
});
});
describe('"split" expression', () => {
test('type requires an input argument', () => {
expectTypeOf<['split']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a separator argument', () => {
expectTypeOf<['split', '1+2+3']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string as the input argument', () => {
expectTypeOf<['split', true, '+']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string as the separator argument', () => {
expectTypeOf<['split', '1+2+3', true]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which splits a string', () => {
expectTypeOf<['split', '1+2+3', '+']>().toExtend<ExpressionSpecification>();
});
});
describe('"join" expression', () => {
test('type requires an input argument', () => {
expectTypeOf<['join']>().not.toExtend<ExpressionSpecification>();
});
test('type requires a separator argument', () => {
expectTypeOf<
['join', ['literal', ['1', '2', '3']]]
>().not.toExtend<ExpressionSpecification>();
});
test('type requires a string as the separator argument', () => {
expectTypeOf<
['join', ['literal', ['1', '2', '3']], true]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which joins an array', () => {
expectTypeOf<
['join', ['literal', ['1', '2', '3']], '+']
>().toExtend<ExpressionSpecification>();
});
});
describe('comparison expressions', () => {
describe('"!=" expression', () => {
test('type accepts expression which compares against literal null value', () => {
expectTypeOf<
['!=', null, ['get', 'nonexistent-prop']]
>().toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a collator', () => {
expectTypeOf<
['!=', null, ['get', 'nonexistent-prop'], ['collator', {locale: 'mi-NZ'}]]
>().toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a var', () => {
expectTypeOf<
[
'let',
'myVariable',
['collator', {'diacritic-sensitive': true}],
['!=', 'münchen', 'munchen', ['var', 'myVariable']]
]
>().toExtend<ExpressionSpecification>();
});
test('does not allow the third argument to be any other expression type', () => {
expectTypeOf<
['!=', 'münchen', 'munchen', ['!', true]]
>().not.toExtend<ExpressionSpecification>();
});
});
describe('"==" expression', () => {
test('type accepts expression which compares expression input against literal input', () => {
expectTypeOf<
['==', ['get', 'MILITARYAIRPORT'], 1]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which compares against literal null value', () => {
expectTypeOf<
['==', null, ['get', 'nonexistent-prop']]
>().toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a collator', () => {
expectTypeOf<
['==', null, ['get', 'nonexistent-prop'], ['collator', {locale: 'mi-NZ'}]]
>().toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a var', () => {
expectTypeOf<
[
'let',
'myVariable',
['collator', {'diacritic-sensitive': true}],
['==', 'münchen', 'munchen', ['var', 'myVariable']]
]
>().toExtend<ExpressionSpecification>();
});
test('does not allow the third argument to be any other expression type', () => {
expectTypeOf<
['==', 'münchen', 'munchen', ['!', true]]
>().not.toExtend<ExpressionSpecification>();
});
});
describe('"<" expression', () => {
test('type rejects boolean input', () => {
expectTypeOf<['<', -1, true]>().not.toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a collator', () => {
expectTypeOf<
['<', 'ä', 'a', ['collator', {locale: 'sv'}]]
>().toExtend<ExpressionSpecification>();
});
test('allows the third argument to be a var', () => {
expectTypeOf<
[
'let',
'myVariable',
['collator', {locale: 'sv'}],
['<', 'ä', 'a', ['var', 'myVariable']]
]
>().toExtend<ExpressionSpecification>();
});
test('does not allow the third argument to be any other expression type', () => {
expectTypeOf<
['<', 'ä', 'a', ['get', 'prop']]
>().not.toExtend<ExpressionSpecification>();
});
});
describe('"<=" expression', () => {
test('type rejects boolean input', () => {
expectTypeOf<['<=', 0, true]>().not.toExtend<ExpressionSpecification>();
});
});
describe('">" expression', () => {
test('type rejects boolean input', () => {
expectTypeOf<['>', 1, true]>().not.toExtend<ExpressionSpecification>();
});
});
describe('">=" expression', () => {
test('type rejects boolean input', () => {
expectTypeOf<['>=', 1, true]>().not.toExtend<ExpressionSpecification>();
});
});
});
describe('"any" expression', () => {
test('type accepts expression which has no arguments', () => {
expectTypeOf<['any']>().toExtend<ExpressionSpecification>();
});
});
describe('"case" expression', () => {
test('type accepts expression which returns the string output of the first matching condition', () => {
expectTypeOf<
[
'case',
['==', ['get', 'CAPITAL'], 1],
'city-capital',
['>=', ['get', 'POPULATION'], 1000000],
'city-1M',
['>=', ['get', 'POPULATION'], 500000],
'city-500k',
['>=', ['get', 'POPULATION'], 100000],
'city-100k',
'city'
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which returns the evaluated output of the first matching condition', () => {
expectTypeOf<
[
'case',
['has', 'point_count'],
['interpolate', ['linear'], ['get', 'point_count'], 2, '#ccc', 10, '#444'],
['has', 'priorityValue'],
['interpolate', ['linear'], ['get', 'priorityValue'], 0, '#ff9', 1, '#f66'],
'#fcaf3e'
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which has literal null output', () => {
expectTypeOf<
['case', false, ['get', 'prop'], true, null, 'fallback']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which has literal null fallback', () => {
expectTypeOf<['case', false, ['get', 'prop'], null]>().toExtend<ExpressionSpecification>();
});
});
describe('"match" expression', () => {
test('type requires label to be string literal, number literal, string literal array, or number literal array', () => {
expectTypeOf<
['match', 4, true, 'matched', 'fallback']
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['match', 4, [true], 'matched', 'fallback']
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['match', 4, [4, '4'], 'matched', 'fallback']
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['match', 4, ['literal', [4]], 'matched', 'fallback']
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which matches number input against number label', () => {
expectTypeOf<
['match', 2, [0], 'o1', 1, 'o2', 2, 'o3', 'fallback']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which matches string input against string label', () => {
expectTypeOf<
['match', 'c', 'a', 'o1', ['b'], 'o2', 'c', 'o3', 'fallback']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which matches number input against number array label', () => {
expectTypeOf<
['match', 2, 0, 'o1', [1, 2, 3], 'o2', 'fallback']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which matches string input against string array label', () => {
expectTypeOf<
['match', 'c', 'a', 'o1', ['b', 'c', 'd'], 'o2', 'fallback']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which has a non-literal input', () => {
expectTypeOf<
['match', ['get', 'TYPE'], ['ADIZ', 'AMA', 'AWY'], true, false]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which has an expression output', () => {
expectTypeOf<
['match', ['get', 'id'], 'exampleID', ['get', 'iconNameFocused'], ['get', 'iconName']]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which has literal null output', () => {
expectTypeOf<
['match', 1, 0, ['get', 'prop'], 1, null, 'fallback']
>().toExtend<ExpressionSpecification>();
});
});
describe('"within" expression', () => {
test('type requires a GeoJSON input', () => {
expectTypeOf<['within']>().not.toExtend<ExpressionSpecification>();
});
test('type rejects an expression as input', () => {
expectTypeOf<
['within', ['literal', {type: 'Polygon'; coordinates: []}]]
>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a second argument', () => {
expectTypeOf<
['within', {type: 'Polygon'; coordinates: []}, 'second arg']
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which checks if feature fully contained within input GeoJSON geometry', () => {
expectTypeOf<
[
'within',
{
type: 'Polygon';
coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]];
}
]
>().toExtend<ExpressionSpecification>();
});
});
describe('interpolation expressions', () => {
describe('linear interpolation type', () => {
test('type works with "interpolate" expression', () => {
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 0, 10, 1, 20]
>().toExtend<ExpressionSpecification>();
});
});
describe('exponential interpolation type', () => {
test('type requires a number literal as the base argument', () => {
expectTypeOf<
['interpolate', ['exponential', ['+', 0.1, 0.4]], ['zoom'], 0, 10, 1, 100]
>().not.toExtend<ExpressionSpecification>();
});
test('type works with "interpolate" expression', () => {
expectTypeOf<
['interpolate', ['exponential', 1.1], ['zoom'], 0, 10, 1, 20]
>().toExtend<ExpressionSpecification>();
});
});
describe('cubic-bezier interpolation type', () => {
test('type requires four numeric literal control point arguments', () => {
expectTypeOf<
[
'interpolate',
['cubic-bezier', 0.4, 0, ['literal', 0.6], 1],
['zoom'],
2,
0,
8,
100
]
>().not.toExtend<ExpressionSpecification>();
});
test('type rejects a fifth control point argument', () => {
expectTypeOf<
['interpolate', ['cubic-bezier', 0.4, 0, 0.6, 1, 0.8], ['zoom'], 2, 0, 8, 100]
>().not.toExtend<ExpressionSpecification>();
});
test('type works with "interpolate" expression', () => {
expectTypeOf<
['interpolate', ['cubic-bezier', 0.4, 0, 0.6, 1], ['zoom'], 0, 0, 10, 100]
>().toExtend<ExpressionSpecification>();
});
});
describe('"interpolate" expression', () => {
test('type requires stop outputs to be a number, color, number array, color array, or projection', () => {
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 0, false, 2, 1024]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
[
'interpolate',
['linear'],
['zoom'],
0,
[10, 20, 30],
0.5,
[20, 30, 40],
1,
[30, 40, 50]
]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 0, {prop: 'foo'}, 2, {prop: 'bar'}]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates with feature property input', () => {
expectTypeOf<
[
'interpolate',
['linear'],
['get', 'point_count'],
2,
['/', 2, ['get', 'point_count']],
10,
['*', 4, ['get', 'point_count']]
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between number outputs', () => {
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 0, 0, 0.5, ['*', 2, 5], 1, 100]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color outputs', () => {
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 2, 'white', 4, 'black']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between number array outputs', () => {
expectTypeOf<
[
'interpolate',
['linear'],
['zoom'],
8,
['literal', [2, 3]],
10,
['literal', [4, 5]]
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color array outputs', () => {
expectTypeOf<
[
'interpolate',
['linear'],
['zoom'],
8,
['literal', ['white', 'black']],
10,
['literal', ['black', 'white']]
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between projection outputs', () => {
expectTypeOf<
['interpolate', ['linear'], ['zoom'], 8, 'vertical-perspective', 10, 'mercator']
>().toExtend<ExpressionSpecification>();
});
});
describe('"interpolate-hcl" expression', () => {
test('type requires stop outputs to be a color', () => {
expectTypeOf<
['interpolate-hcl', ['linear'], ['zoom'], 0, false, 2, 1024]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
[
'interpolate-hcl',
['linear'],
['zoom'],
0,
[10, 20, 30],
0.5,
[20, 30, 40],
1,
[30, 40, 50]
]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['interpolate-hcl', ['linear'], ['zoom'], 0, {prop: 'foo'}, 2, {prop: 'bar'}]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color outputs', () => {
expectTypeOf<
['interpolate-hcl', ['linear'], ['zoom'], 2, 'white', 4, 'black']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color array outputs', () => {
expectTypeOf<
[
'interpolate-hcl',
['linear'],
['zoom'],
8,
['literal', ['white', 'black']],
10,
['literal', ['black', 'white']]
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between non-literal color array outputs', () => {
// eslint-disable-next-line
const obj = {'colors-8': ['white', 'black'], 'colors-10': ['black', 'white']};
expectTypeOf<
[
'interpolate-hcl',
['linear'],
['zoom'],
8,
['get', 'colors-8', ['literal', typeof obj]],
10,
['get', 'colors-10', ['literal', typeof obj]]
]
>().toExtend<ExpressionSpecification>();
});
});
describe('"interpolate-lab" expression', () => {
test('type requires stop outputs to be a color', () => {
expectTypeOf<
['interpolate-lab', ['linear'], ['zoom'], 0, false, 2, 1024]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
[
'interpolate-lab',
['linear'],
['zoom'],
0,
[10, 20, 30],
0.5,
[20, 30, 40],
1,
[30, 40, 50]
]
>().not.toExtend<ExpressionSpecification>();
expectTypeOf<
['interpolate-lab', ['linear'], ['zoom'], 0, {prop: 'foo'}, 2, {prop: 'bar'}]
>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color outputs', () => {
expectTypeOf<
['interpolate-lab', ['linear'], ['zoom'], 2, 'white', 4, 'black']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between color array outputs', () => {
expectTypeOf<
[
'interpolate-lab',
['linear'],
['zoom'],
8,
['literal', ['white', 'black']],
10,
['literal', ['black', 'white']]
]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which interpolates between non-literal color array outputs', () => {
// eslint-disable-next-line
const obj = {'colors-8': ['white', 'black'], 'colors-10': ['black', 'white']};
expectTypeOf<
[
'interpolate-lab',
['linear'],
['zoom'],
8,
['get', 'colors-8', ['literal', typeof obj]],
10,
['get', 'colors-10', ['literal', typeof obj]]
]
>().toExtend<ExpressionSpecification>();
});
});
});
describe('"step" expression', () => {
test('type accepts expression which outputs stepped numbers', () => {
expectTypeOf<
['step', ['get', 'point_count'], 0.6, 50, 0.7, 200, 0.8]
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which outputs stepped colors', () => {
expectTypeOf<
['step', ['get', 'point_count'], '#ddd', 50, '#eee', 200, '#fff']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which outputs stepped projections', () => {
expectTypeOf<
['step', ['zoom'], 'vertical-perspective', 10, 'mercator']
>().toExtend<ExpressionSpecification>();
});
test('type accepts expression which outputs stepped multi-input projections', () => {
expectTypeOf<
[
'step',
['zoom'],
['literal', ['vertical-perspective', 'mercator', 0.5]],
10,
'mercator'
]
>().toExtend<ExpressionSpecification>();
});
});
describe('"e" expression', () => {
test('type rejects any arguments', () => {
expectTypeOf<['e', 2]>().not.toExtend<ExpressionSpecification>();
});
test('type accepts expression which returns the mathematical constant e', () => {
expectTypeOf<['e']>().toExtend<ExpressionSpecification>();
});
});
describe('nonexistent operators', () => {
test('ExpressionSpecification type does not contain "ExpressionSpecification" expression', () => {
type ExpressionSpecificationExpression = Extract<
ExpressionSpecification,
['ExpressionSpecification', ...any[]]
>;
expectTypeOf<ExpressionSpecificationExpression>().not.toExtend<ExpressionSpecification>();
});
});
test('ExpressionSpecification type supports common variable insertion patterns', () => {
// Checks the ability for the ExpressionSpecification type to allow arguments to be provided via constants (as opposed to in-line).
// As in most cases the styling is read from JSON, these are rather optional tests.
// eslint-disable-next-line
const colorStops = [0, 'red', 0.5, 'green', 1, 'blue'];
expectTypeOf<
['interpolate', ['linear'], ['line-progress'], ...typeof colorStops]
>().toExtend<ExpressionSpecification>();
expectTypeOf<
['interpolate-hcl', ['linear'], ['line-progress'], ...typeof colorStops]
>().toExtend<ExpressionSpecification>();
expectTypeOf<
['interpolate-lab', ['linear'], ['line-progress'], ...typeof colorStops]
>().toExtend<ExpressionSpecification>();
// eslint-disable-next-line
const [firstOutput, ...steps] = ['#df2d43', 50, '#df2d43', 200, '#df2d43'];
expectTypeOf<
['step', ['get', 'point_count'], typeof firstOutput, ...typeof steps]
>().toExtend<ExpressionSpecification>();
// eslint-disable-next-line
const strings = ['first', 'second', 'third'];
expectTypeOf<['concat', ...typeof strings]>().toExtend<ExpressionSpecification>();
// eslint-disable-next-line
const values: (ExpressionInputType | ExpressionSpecification)[] = [
['get', 'name'],
['get', 'code'],
'NONE'
]; // type is necessary!
expectTypeOf<['coalesce', ...typeof values]>().toExtend<ExpressionSpecification>();
});

View File

@@ -0,0 +1,199 @@
import {
createPropertyExpression,
Feature,
GlobalProperties,
StylePropertyExpression
} from '../expression';
import {expressions} from './definitions';
import v8 from '../reference/v8.json' with {type: 'json'};
import {createExpression, StylePropertySpecification} from '..';
import {ExpressionParsingError} from './parsing_error';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {describe, test, expect, vi} from 'vitest';
// filter out internal "error" and "filter-*" expressions from definition list
const filterExpressionRegex = /filter-/;
const definitionList = Object.keys(expressions)
.filter((expression) => {
return expression !== 'error' && !filterExpressionRegex.exec(expression);
})
.sort();
test('v8.json includes all definitions from style-spec', () => {
const v8List = Object.keys(v8.expression_name.values);
const v8SupportedList = v8List.filter((expression) => {
//filter out expressions that are not supported in GL-JS
return !!v8.expression_name.values[expression]['sdk-support']['basic functionality']['js'];
});
expect(definitionList).toEqual(v8SupportedList.sort());
});
describe('createPropertyExpression', () => {
test('prohibits non-interpolable properties from using an "interpolate" expression', () => {
const {result, value} = createPropertyExpression(
['interpolate', ['linear'], ['zoom'], 0, 0, 10, 10],
{
type: 'number',
'property-type': 'data-constant',
expression: {
interpolated: false,
parameters: ['zoom']
}
} as StylePropertySpecification
);
expect(result).toBe('error');
expect(value as ExpressionParsingError[]).toHaveLength(1);
expect(value[0].message).toBe(
'"interpolate" expressions cannot be used with this property'
);
});
test('sets globalStateRefs', () => {
const {value} = createPropertyExpression(
[
'case',
['>', ['global-state', 'stateKey'], 0],
100,
['global-state', 'anotherStateKey']
],
{
type: 'number',
'property-type': 'data-driven',
expression: {
interpolated: false,
parameters: ['zoom', 'feature']
}
} as any as StylePropertySpecification
) as {value: StylePropertyExpression};
expect(value.globalStateRefs).toEqual(new Set(['stateKey', 'anotherStateKey']));
});
});
describe('evaluate expression', () => {
test('silently falls back to default for nullish values', () => {
const {value} = createPropertyExpression(['global-state', 'x'], {
type: null,
default: 42,
'property-type': 'data-driven',
transition: false
}) as {value: StylePropertyExpression};
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate({globalState: {x: 5}, zoom: 10} as GlobalProperties)).toBe(5);
expect(console.warn).not.toHaveBeenCalled();
expect(value.evaluate({globalState: {}, zoom: 10} as GlobalProperties)).toBe(42);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state as expression property', () => {
const {value} = createPropertyExpression(
['global-state', 'x'],
{
type: null,
default: 42,
'property-type': 'data-driven',
transition: false
},
{x: 5}
) as {value: StylePropertyExpression};
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate({globalState: {x: 15}, zoom: 10} as GlobalProperties)).toBe(5);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state as expression property of zoom dependent expression', () => {
const {value} = createPropertyExpression(
['interpolate', ['linear'], ['zoom'], 10, ['global-state', 'x'], 20, 50],
{
type: 'number',
default: 42,
'property-type': 'data-driven',
expression: {
interpolated: true,
parameters: ['zoom']
}
} as StylePropertySpecification,
{x: 5}
) as {value: StylePropertyExpression};
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate({globalState: {x: 15}, zoom: 10} as GlobalProperties)).toBe(5);
expect(console.warn).not.toHaveBeenCalled();
});
test('warns and falls back to default for invalid enum values', () => {
const {value} = createPropertyExpression(['get', 'x'], {
type: 'enum',
values: {a: {}, b: {}, c: {}},
default: 'a',
'property-type': 'data-driven',
expression: {
interpolated: false,
parameters: ['zoom', 'feature']
}
} as any as StylePropertySpecification) as {value: StylePropertyExpression};
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.kind).toBe('source');
expect(
value.evaluate({} as GlobalProperties, {properties: {x: 'b'}} as any as Feature)
).toBe('b');
expect(
value.evaluate({} as GlobalProperties, {properties: {x: 'invalid'}} as any as Feature)
).toBe('a');
expect(console.warn).toHaveBeenCalledWith(
'Expected value to be one of "a", "b", "c", but found "invalid" instead.'
);
});
test('warns for invalid variableAnchorOffsetCollection values', () => {
const {value} = createPropertyExpression(['get', 'x'], {
type: 'variableAnchorOffsetCollection',
'property-type': 'data-driven',
transition: false,
expression: {
interpolated: false,
parameters: ['zoom', 'feature']
}
}) as {value: StylePropertyExpression};
const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.kind).toBe('source');
expect(
value.evaluate({} as GlobalProperties, {properties: {x: 'invalid'}} as any as Feature)
).toBeNull();
expect(console.warn).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledWith(
"Could not parse variableAnchorOffsetCollection from value 'invalid'"
);
warnMock.mockClear();
expect(
value.evaluate(
{} as GlobalProperties,
{properties: {x: ['top', [2, 2]]}} as any as Feature
)
).toEqual(new VariableAnchorOffsetCollection(['top', [2, 2]]));
expect(console.warn).not.toHaveBeenCalled();
});
});
describe('nonexistent operators', () => {
test('"ExpressionSpecification" operator does not exist', () => {
const response = createExpression(['ExpressionSpecification']);
expect(response.result).toBe('error');
expect((response.value as ExpressionParsingError[])[0].message).toContain(
'Unknown expression \"ExpressionSpecification\".'
);
});
});

View File

@@ -0,0 +1,28 @@
import type {Type} from './types';
import type {ParsingContext} from './parsing_context';
import type {EvaluationContext} from './evaluation_context';
/**
* Expression
*/
export interface Expression {
readonly type: Type;
evaluate(ctx: EvaluationContext): any;
eachChild(fn: (a: Expression) => void): void;
/**
* Statically analyze the expression, attempting to enumerate possible outputs. Returns
* false if the complete set of outputs is statically undecidable, otherwise true.
*/
outputDefined(): boolean;
}
export type ExpressionParser = (
args: ReadonlyArray<unknown>,
context: ParsingContext
) => Expression;
export type ExpressionRegistration = {
new (...args: any): Expression;
} & {
readonly parse: ExpressionParser;
};
export type ExpressionRegistry = {[_: string]: ExpressionRegistration};

View File

@@ -0,0 +1,172 @@
import {normalizePropertyExpression, StyleExpression} from '.';
import {StylePropertySpecification} from '..';
import {Color} from './types/color';
import {ColorArray} from './types/color_array';
import {NumberArray} from './types/number_array';
import {Padding} from './types/padding';
import type {Expression} from './expression';
import {describe, test, expect, vi} from 'vitest';
function stylePropertySpecification(type): StylePropertySpecification {
return {
type: type,
'property-type': 'constant',
expression: {
interpolated: false,
parameters: []
},
transition: false
};
}
describe('normalizePropertyExpression expressions', () => {
test('normalizePropertyExpression<ColorArray>', () => {
const expression = normalizePropertyExpression<ColorArray>(
['literal', ['#FF0000', 'black']],
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red, Color.black]);
});
test('normalizePropertyExpression<ColorArray> single value', () => {
const expression = normalizePropertyExpression<ColorArray>(
['literal', '#FF0000'],
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red]);
});
test('normalizePropertyExpression<NumberArray>', () => {
const expression = normalizePropertyExpression<NumberArray>(
['literal', [1, 2]],
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2]);
});
test('normalizePropertyExpression<NumberArray> single value', () => {
const expression = normalizePropertyExpression<NumberArray>(
['literal', 1],
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1]);
});
test('normalizePropertyExpression<Padding>', () => {
const expression = normalizePropertyExpression<Padding>(
['literal', [1, 2]],
stylePropertySpecification('padding')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2, 1, 2]);
});
});
describe('normalizePropertyExpression objects', () => {
test('normalizePropertyExpression<ColorArray>', () => {
const expression = normalizePropertyExpression<ColorArray>(
ColorArray.parse(['#FF0000', 'black']),
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red, Color.black]);
});
test('normalizePropertyExpression<ColorArray> single value', () => {
const expression = normalizePropertyExpression<ColorArray>(
ColorArray.parse('#FF0000'),
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red]);
});
test('normalizePropertyExpression<NumberArray>', () => {
const expression = normalizePropertyExpression<NumberArray>(
NumberArray.parse([1, 2]),
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2]);
});
test('normalizePropertyExpression<NumberArray> single value', () => {
const expression = normalizePropertyExpression<NumberArray>(
NumberArray.parse(1),
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1]);
});
test('normalizePropertyExpression<Padding>', () => {
const expression = normalizePropertyExpression<Padding>(
Padding.parse([1, 2]),
stylePropertySpecification('padding')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2, 1, 2]);
});
});
describe('normalizePropertyExpression raw values', () => {
test('normalizePropertyExpression<ColorArray>', () => {
const expression = normalizePropertyExpression<ColorArray>(
['#FF0000', 'black'] as any,
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red, Color.black]);
});
test('normalizePropertyExpression<ColorArray> single value', () => {
const expression = normalizePropertyExpression<ColorArray>(
'#FF0000' as any,
stylePropertySpecification('colorArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([Color.red]);
});
test('normalizePropertyExpression<NumberArray>', () => {
const expression = normalizePropertyExpression<NumberArray>(
[1, 2] as any,
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2]);
});
test('normalizePropertyExpression<NumberArray> single value', () => {
const expression = normalizePropertyExpression<NumberArray>(
1 as any,
stylePropertySpecification('numberArray')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1]);
});
test('normalizePropertyExpression<Padding>', () => {
const expression = normalizePropertyExpression<Padding>(
[1, 2] as any,
stylePropertySpecification('padding')
);
expect(expression.evaluate({zoom: 0}).values).toEqual([1, 2, 1, 2]);
});
});
describe('StyleExpressions', () => {
test('ignore random fields when adding global state ', () => {
const expression = {
evaluate: vi.fn()
} as any as Expression;
const styleExpression = new StyleExpression(
expression,
{
type: null,
default: 42,
'property-type': 'data-driven',
transition: false
} as StylePropertySpecification,
{x: 5} as Record<string, any>
);
styleExpression.evaluate({zoom: 10, a: 20, b: 30} as any);
expect(expression.evaluate).toHaveBeenCalled();
const params = (expression.evaluate as any).mock.calls[0][0].globals;
expect(params).toHaveProperty('zoom', 10);
expect(params).toHaveProperty('globalState', {x: 5});
expect(params).not.toHaveProperty('a');
expect(params).not.toHaveProperty('b');
});
});

View File

@@ -0,0 +1,749 @@
import {extendBy} from '../util/extend';
import {ExpressionParsingError} from './parsing_error';
import {ParsingContext} from './parsing_context';
import {EvaluationContext} from './evaluation_context';
import {
CompoundExpression,
isFeatureConstant,
isGlobalPropertyConstant,
isStateConstant,
isExpressionConstant
} from './compound_expression';
import {Step} from './definitions/step';
import {Interpolate} from './definitions/interpolate';
import {Coalesce} from './definitions/coalesce';
import {Let} from './definitions/let';
import {expressions} from './definitions';
import {RuntimeError} from './runtime_error';
import {success, error} from '../util/result';
import {
supportsPropertyExpression,
supportsZoomExpression,
supportsInterpolation
} from '../util/properties';
import {
ColorType,
StringType,
NumberType,
BooleanType,
ValueType,
FormattedType,
PaddingType,
ResolvedImageType,
VariableAnchorOffsetCollectionType,
array,
type Type,
type EvaluationKind,
ProjectionDefinitionType,
NumberArrayType,
ColorArrayType
} from './types';
import type {Value} from './values';
import type {Expression} from './expression';
import {type StylePropertySpecification} from '..';
import type {Result} from '../util/result';
import type {InterpolationType} from './definitions/interpolate';
import type {
PaddingSpecification,
NumberArraySpecification,
ColorArraySpecification,
PropertyValueSpecification,
VariableAnchorOffsetCollectionSpecification
} from '../types.g';
import type {FormattedSection} from './types/formatted';
import type {Point2D} from '../point2d';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {isFunction, createFunction} from '../function';
import {Color} from './types/color';
import {Padding} from './types/padding';
import {NumberArray} from './types/number_array';
import {ColorArray} from './types/color_array';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {ProjectionDefinition} from './types/projection_definition';
import {GlobalState} from './definitions/global_state';
export type Feature = {
readonly type:
| 0
| 1
| 2
| 3
| 'Unknown'
| 'Point'
| 'MultiPoint'
| 'LineString'
| 'MultiLineString'
| 'Polygon'
| 'MultiPolygon';
readonly id?: any;
readonly properties: {[_: string]: any};
readonly patterns?: {
[_: string]: {
min: string;
mid: string;
max: string;
};
};
readonly dashes?: {
[_: string]: {
min: string;
mid: string;
max: string;
};
};
readonly geometry?: Array<Array<Point2D>>;
};
export type FeatureState = {[_: string]: any};
export type GlobalProperties = Readonly<{
zoom: number;
heatmapDensity?: number;
elevation?: number;
lineProgress?: number;
isSupportedScript?: (_: string) => boolean;
accumulated?: Value;
globalState?: Record<string, any>;
}>;
export class StyleExpression {
expression: Expression;
_evaluator: EvaluationContext;
_defaultValue: Value;
_warningHistory: {[key: string]: boolean};
_enumValues: {[_: string]: any};
readonly _globalState: Record<string, any>;
constructor(
expression: Expression,
propertySpec?: StylePropertySpecification | null,
globalState?: Record<string, any>
) {
this.expression = expression;
this._warningHistory = {};
this._evaluator = new EvaluationContext();
this._defaultValue = propertySpec ? getDefaultValue(propertySpec) : null;
this._enumValues =
propertySpec && propertySpec.type === 'enum' ? propertySpec.values : null;
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
this._evaluator.globals = globals;
this._evaluator.feature = feature;
this._evaluator.featureState = featureState;
this._evaluator.canonical = canonical;
this._evaluator.availableImages = availableImages || null;
this._evaluator.formattedSection = formattedSection;
return this.expression.evaluate(this._evaluator);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
this._evaluator.globals = globals;
this._evaluator.feature = feature || null;
this._evaluator.featureState = featureState || null;
this._evaluator.canonical = canonical;
this._evaluator.availableImages = availableImages || null;
this._evaluator.formattedSection = formattedSection || null;
try {
const val = this.expression.evaluate(this._evaluator);
if (val === null || val === undefined || (typeof val === 'number' && val !== val)) {
return this._defaultValue;
}
if (this._enumValues && !(val in this._enumValues)) {
throw new RuntimeError(
`Expected value to be one of ${Object.keys(this._enumValues)
.map((v) => JSON.stringify(v))
.join(', ')}, but found ${JSON.stringify(val)} instead.`
);
}
return val;
} catch (e) {
if (!this._warningHistory[e.message]) {
this._warningHistory[e.message] = true;
if (typeof console !== 'undefined') {
console.warn(e.message);
}
}
return this._defaultValue;
}
}
}
export function isExpression(expression: unknown) {
return (
Array.isArray(expression) &&
expression.length > 0 &&
typeof expression[0] === 'string' &&
expression[0] in expressions
);
}
/**
* Parse and typecheck the given style spec JSON expression. If
* options.defaultValue is provided, then the resulting StyleExpression's
* `evaluate()` method will handle errors by logging a warning (once per
* message) and returning the default value. Otherwise, it will throw
* evaluation errors.
*
* @private
*/
export function createExpression(
expression: unknown,
propertySpec?: StylePropertySpecification | null,
globalState?: Record<string, any>
): Result<StyleExpression, Array<ExpressionParsingError>> {
const parser = new ParsingContext(
expressions,
isExpressionConstant,
[],
propertySpec ? getExpectedType(propertySpec) : undefined
);
// For string-valued properties, coerce to string at the top level rather than asserting.
const parsed = parser.parse(
expression,
undefined,
undefined,
undefined,
propertySpec && propertySpec.type === 'string' ? {typeAnnotation: 'coerce'} : undefined
);
if (!parsed) {
return error(parser.errors);
}
return success(new StyleExpression(parsed, propertySpec, globalState));
}
export class ZoomConstantExpression<Kind extends EvaluationKind> {
kind: Kind;
isStateDependent: boolean;
globalStateRefs: Set<string>;
_styleExpression: StyleExpression;
readonly _globalState: Record<string, any>;
constructor(kind: Kind, expression: StyleExpression, globalState?: Record<string, any>) {
this.kind = kind;
this._styleExpression = expression;
this.isStateDependent =
kind !== ('constant' as EvaluationKind) && !isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluateWithoutErrorHandling(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluate(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
}
export class ZoomDependentExpression<Kind extends EvaluationKind> {
kind: Kind;
zoomStops: Array<number>;
isStateDependent: boolean;
globalStateRefs: Set<string>;
_styleExpression: StyleExpression;
interpolationType: InterpolationType;
readonly _globalState: Record<string, any>;
constructor(
kind: Kind,
expression: StyleExpression,
zoomStops: Array<number>,
interpolationType?: InterpolationType,
globalState?: Record<string, any>
) {
this.kind = kind;
this.zoomStops = zoomStops;
this._styleExpression = expression;
this.isStateDependent =
kind !== ('camera' as EvaluationKind) && !isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this.interpolationType = interpolationType;
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluateWithoutErrorHandling(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluate(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
interpolationFactor(input: number, lower: number, upper: number): number {
if (this.interpolationType) {
return Interpolate.interpolationFactor(this.interpolationType, input, lower, upper);
} else {
return 0;
}
}
}
export function isZoomExpression(
expression: any
): expression is ZoomConstantExpression<'source'> | ZoomDependentExpression<'source'> {
return (expression as ZoomConstantExpression<'source'>)._styleExpression !== undefined;
}
export type ConstantExpression = {
kind: 'constant';
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>
) => any;
};
export type SourceExpression = {
kind: 'source';
isStateDependent: boolean;
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
) => any;
};
export type CameraExpression = {
kind: 'camera';
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>
) => any;
readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
interpolationType: InterpolationType;
};
export type CompositeExpression = {
kind: 'composite';
isStateDependent: boolean;
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
) => any;
readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
interpolationType: InterpolationType;
};
export type StylePropertyExpression =
| ConstantExpression
| SourceExpression
| CameraExpression
| CompositeExpression;
export function createPropertyExpression(
expressionInput: unknown,
propertySpec: StylePropertySpecification,
globalState?: Record<string, any>
): Result<StylePropertyExpression, Array<ExpressionParsingError>> {
const expression = createExpression(expressionInput, propertySpec, globalState);
if (expression.result === 'error') {
return expression;
}
const parsed = expression.value.expression;
const isFeatureConstantResult = isFeatureConstant(parsed);
if (!isFeatureConstantResult && !supportsPropertyExpression(propertySpec)) {
return error([new ExpressionParsingError('', 'data expressions not supported')]);
}
const isZoomConstant = isGlobalPropertyConstant(parsed, ['zoom']);
if (!isZoomConstant && !supportsZoomExpression(propertySpec)) {
return error([new ExpressionParsingError('', 'zoom expressions not supported')]);
}
const zoomCurve = findZoomCurve(parsed);
if (!zoomCurve && !isZoomConstant) {
return error([
new ExpressionParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
)
]);
} else if (zoomCurve instanceof ExpressionParsingError) {
return error([zoomCurve]);
} else if (zoomCurve instanceof Interpolate && !supportsInterpolation(propertySpec)) {
return error([
new ExpressionParsingError(
'',
'"interpolate" expressions cannot be used with this property'
)
]);
}
if (!zoomCurve) {
return success(
isFeatureConstantResult
? (new ZoomConstantExpression(
'constant',
expression.value,
globalState
) as ConstantExpression)
: (new ZoomConstantExpression(
'source',
expression.value,
globalState
) as SourceExpression)
);
}
const interpolationType =
zoomCurve instanceof Interpolate ? zoomCurve.interpolation : undefined;
return success(
isFeatureConstantResult
? (new ZoomDependentExpression(
'camera',
expression.value,
zoomCurve.labels,
interpolationType,
globalState
) as CameraExpression)
: (new ZoomDependentExpression(
'composite',
expression.value,
zoomCurve.labels,
interpolationType,
globalState
) as CompositeExpression)
);
}
// serialization wrapper for old-style stop functions normalized to the
// expression interface
export class StylePropertyFunction<T> {
_parameters: PropertyValueSpecification<T>;
_specification: StylePropertySpecification;
kind: EvaluationKind;
evaluate: (globals: GlobalProperties, feature?: Feature) => any;
interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
constructor(
parameters: PropertyValueSpecification<T>,
specification: StylePropertySpecification
) {
this._parameters = parameters;
this._specification = specification;
extendBy(this, createFunction(this._parameters, this._specification));
}
static deserialize<T>(serialized: {
_parameters: PropertyValueSpecification<T>;
_specification: StylePropertySpecification;
}) {
return new StylePropertyFunction(
serialized._parameters,
serialized._specification
) as StylePropertyFunction<T>;
}
static serialize<T>(input: StylePropertyFunction<T>) {
return {
_parameters: input._parameters,
_specification: input._specification
};
}
}
export function normalizePropertyExpression<T>(
value: PropertyValueSpecification<T>,
specification: StylePropertySpecification,
globalState?: Record<string, any>
): StylePropertyExpression {
if (isFunction(value)) {
return new StylePropertyFunction(value, specification) as any;
} else if (isExpression(value)) {
const expression = createPropertyExpression(value, specification, globalState);
if (expression.result === 'error') {
// this should have been caught in validation
throw new Error(expression.value.map((err) => `${err.key}: ${err.message}`).join(', '));
}
return expression.value;
} else {
let constant: any = value;
if (specification.type === 'color' && typeof value === 'string') {
constant = Color.parse(value);
} else if (
specification.type === 'padding' &&
(typeof value === 'number' || Array.isArray(value))
) {
constant = Padding.parse(value as PaddingSpecification);
} else if (
specification.type === 'numberArray' &&
(typeof value === 'number' || Array.isArray(value))
) {
constant = NumberArray.parse(value as NumberArraySpecification);
} else if (
specification.type === 'colorArray' &&
(typeof value === 'string' || Array.isArray(value))
) {
constant = ColorArray.parse(value as ColorArraySpecification);
} else if (
specification.type === 'variableAnchorOffsetCollection' &&
Array.isArray(value)
) {
constant = VariableAnchorOffsetCollection.parse(
value as VariableAnchorOffsetCollectionSpecification
);
} else if (specification.type === 'projectionDefinition' && typeof value === 'string') {
constant = ProjectionDefinition.parse(value);
}
return {
globalStateRefs: new Set<string>(),
_globalState: null,
kind: 'constant',
evaluate: () => constant
};
}
}
// Zoom-dependent expressions may only use ["zoom"] as the input to a top-level "step" or "interpolate"
// expression (collectively referred to as a "curve"). The curve may be wrapped in one or more "let" or
// "coalesce" expressions.
function findZoomCurve(expression: Expression): Step | Interpolate | ExpressionParsingError | null {
let result = null;
if (expression instanceof Let) {
result = findZoomCurve(expression.result);
} else if (expression instanceof Coalesce) {
for (const arg of expression.args) {
result = findZoomCurve(arg);
if (result) {
break;
}
}
} else if (
(expression instanceof Step || expression instanceof Interpolate) &&
expression.input instanceof CompoundExpression &&
expression.input.name === 'zoom'
) {
result = expression;
}
if (result instanceof ExpressionParsingError) {
return result;
}
expression.eachChild((child) => {
const childResult = findZoomCurve(child);
if (childResult instanceof ExpressionParsingError) {
result = childResult;
} else if (!result && childResult) {
result = new ExpressionParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
);
} else if (result && childResult && result !== childResult) {
result = new ExpressionParsingError(
'',
'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'
);
}
});
return result;
}
export function findGlobalStateRefs(
expression: Expression,
results = new Set<string>()
): Set<string> {
if (expression instanceof GlobalState) {
results.add(expression.key);
}
expression.eachChild((childExpression) => {
findGlobalStateRefs(childExpression, results);
});
return results;
}
function getExpectedType(spec: StylePropertySpecification): Type {
const types = {
color: ColorType,
string: StringType,
number: NumberType,
enum: StringType,
boolean: BooleanType,
formatted: FormattedType,
padding: PaddingType,
numberArray: NumberArrayType,
colorArray: ColorArrayType,
projectionDefinition: ProjectionDefinitionType,
resolvedImage: ResolvedImageType,
variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType
};
if (spec.type === 'array') {
return array(types[spec.value] || ValueType, spec.length);
}
return types[spec.type];
}
function getDefaultValue(spec: StylePropertySpecification): Value {
if (spec.type === 'color' && isFunction(spec.default)) {
// Special case for heatmap-color: it uses the 'default:' to define a
// default color ramp, but createExpression expects a simple value to fall
// back to in case of runtime errors
return new Color(0, 0, 0, 0);
}
switch (spec.type) {
case 'color':
return Color.parse(spec.default) || null;
case 'padding':
return Padding.parse(spec.default) || null;
case 'numberArray':
return NumberArray.parse(spec.default) || null;
case 'colorArray':
return ColorArray.parse(spec.default) || null;
case 'variableAnchorOffsetCollection':
return VariableAnchorOffsetCollection.parse(spec.default) || null;
case 'projectionDefinition':
return ProjectionDefinition.parse(spec.default) || null;
default:
return spec.default === undefined ? null : spec.default;
}
}
function addGlobalState(
globals: GlobalProperties,
globalState: Record<string, any>
): GlobalProperties {
const {zoom, heatmapDensity, elevation, lineProgress, isSupportedScript, accumulated} =
globals ?? {};
return {
zoom,
heatmapDensity,
elevation,
lineProgress,
isSupportedScript,
accumulated,
globalState
};
}

View File

@@ -0,0 +1,237 @@
import {Scope} from './scope';
import {checkSubtype} from './types';
import {ExpressionParsingError} from './parsing_error';
import {Literal} from './definitions/literal';
import {Assertion} from './definitions/assertion';
import {Coercion} from './definitions/coercion';
import {EvaluationContext} from './evaluation_context';
import type {Expression, ExpressionRegistry} from './expression';
import type {Type} from './types';
/**
* State associated parsing at a given point in an expression tree.
* @private
*/
export class ParsingContext {
registry: ExpressionRegistry;
path: Array<number>;
key: string;
scope: Scope;
errors: Array<ExpressionParsingError>;
// The expected type of this expression. Provided only to allow Expression
// implementations to infer argument types: Expression#parse() need not
// check that the output type of the parsed expression matches
// `expectedType`.
expectedType: Type;
/**
* Internal delegate to inConstant function to avoid circular dependency to CompoundExpression
*/
private _isConstant: (expression: Expression) => boolean;
constructor(
registry: ExpressionRegistry,
isConstantFunc: (expression: Expression) => boolean,
path: Array<number> = [],
expectedType?: Type | null,
scope: Scope = new Scope(),
errors: Array<ExpressionParsingError> = []
) {
this.registry = registry;
this.path = path;
this.key = path.map((part) => `[${part}]`).join('');
this.scope = scope;
this.errors = errors;
this.expectedType = expectedType;
this._isConstant = isConstantFunc;
}
/**
* @param expr the JSON expression to parse
* @param index the optional argument index if this expression is an argument of a parent expression that's being parsed
* @param options
* @param options.omitTypeAnnotations set true to omit inferred type annotations. Caller beware: with this option set, the parsed expression's type will NOT satisfy `expectedType` if it would normally be wrapped in an inferred annotation.
* @private
*/
parse(
expr: unknown,
index?: number,
expectedType?: Type | null,
bindings?: Array<[string, Expression]>,
options: {
typeAnnotation?: 'assert' | 'coerce' | 'omit';
} = {}
): Expression {
if (index) {
return this.concat(index, expectedType, bindings)._parse(expr, options);
}
return this._parse(expr, options);
}
_parse(
expr: unknown,
options: {
typeAnnotation?: 'assert' | 'coerce' | 'omit';
}
): Expression {
if (
expr === null ||
typeof expr === 'string' ||
typeof expr === 'boolean' ||
typeof expr === 'number'
) {
expr = ['literal', expr];
}
function annotate(parsed, type, typeAnnotation: 'assert' | 'coerce' | 'omit') {
if (typeAnnotation === 'assert') {
return new Assertion(type, [parsed]);
} else if (typeAnnotation === 'coerce') {
return new Coercion(type, [parsed]);
} else {
return parsed;
}
}
if (Array.isArray(expr)) {
if (expr.length === 0) {
return this.error(
'Expected an array with at least one element. If you wanted a literal array, use ["literal", []].'
) as null;
}
const op = expr[0];
if (typeof op !== 'string') {
this.error(
`Expression name must be a string, but found ${typeof op} instead. If you wanted a literal array, use ["literal", [...]].`,
0
);
return null;
}
const Expr = this.registry[op];
if (Expr) {
let parsed = Expr.parse(expr, this);
if (!parsed) return null;
if (this.expectedType) {
const expected = this.expectedType;
const actual = parsed.type;
// When we expect a number, string, boolean, or array but have a value, wrap it in an assertion.
// When we expect a color or formatted string, but have a string or value, wrap it in a coercion.
// Otherwise, we do static type-checking.
//
// These behaviors are overridable for:
// * The "coalesce" operator, which needs to omit type annotations.
// * String-valued properties (e.g. `text-field`), where coercion is more convenient than assertion.
//
if (
(expected.kind === 'string' ||
expected.kind === 'number' ||
expected.kind === 'boolean' ||
expected.kind === 'object' ||
expected.kind === 'array') &&
actual.kind === 'value'
) {
parsed = annotate(parsed, expected, options.typeAnnotation || 'assert');
} else if (
('projectionDefinition' === expected.kind &&
['string', 'array'].includes(actual.kind)) ||
(['color', 'formatted', 'resolvedImage'].includes(expected.kind) &&
['value', 'string'].includes(actual.kind)) ||
(['padding', 'numberArray'].includes(expected.kind) &&
['value', 'number', 'array'].includes(actual.kind)) ||
('colorArray' === expected.kind &&
['value', 'string', 'array'].includes(actual.kind)) ||
('variableAnchorOffsetCollection' === expected.kind &&
['value', 'array'].includes(actual.kind))
) {
parsed = annotate(parsed, expected, options.typeAnnotation || 'coerce');
} else if (this.checkSubtype(expected, actual)) {
return null;
}
}
// If an expression's arguments are all literals, we can evaluate
// it immediately and replace it with a literal value in the
// parsed/compiled result. Expressions that expect an image should
// not be resolved here so we can later get the available images.
if (
!(parsed instanceof Literal) &&
parsed.type.kind !== 'resolvedImage' &&
this._isConstant(parsed)
) {
const ec = new EvaluationContext();
try {
parsed = new Literal(parsed.type, parsed.evaluate(ec));
} catch (e) {
this.error(e.message);
return null;
}
}
return parsed;
}
return this.error(
`Unknown expression "${op}". If you wanted a literal array, use ["literal", [...]].`,
0
) as null;
} else if (typeof expr === 'undefined') {
return this.error("'undefined' value invalid. Use null instead.") as null;
} else if (typeof expr === 'object') {
return this.error('Bare objects invalid. Use ["literal", {...}] instead.') as null;
} else {
return this.error(`Expected an array, but found ${typeof expr} instead.`) as null;
}
}
/**
* Returns a copy of this context suitable for parsing the subexpression at
* index `index`, optionally appending to 'let' binding map.
*
* Note that `errors` property, intended for collecting errors while
* parsing, is copied by reference rather than cloned.
* @private
*/
concat(index: number, expectedType?: Type | null, bindings?: Array<[string, Expression]>) {
const path = typeof index === 'number' ? this.path.concat(index) : this.path;
const scope = bindings ? this.scope.concat(bindings) : this.scope;
return new ParsingContext(
this.registry,
this._isConstant,
path,
expectedType || null,
scope,
this.errors
);
}
/**
* Push a parsing (or type checking) error into the `this.errors`
* @param error The message
* @param keys Optionally specify the source of the error at a child
* of the current expression at `this.key`.
* @private
*/
error(error: string, ...keys: Array<number>) {
const key = `${this.key}${keys.map((k) => `[${k}]`).join('')}`;
this.errors.push(new ExpressionParsingError(key, error));
}
/**
* Returns null if `t` is a subtype of `expected`; otherwise returns an
* error message and also pushes it to `this.errors`.
* @param expected The expected type
* @param t The actual type
* @returns null if `t` is a subtype of `expected`; otherwise returns an error message
*/
checkSubtype(expected: Type, t: Type): string {
const error = checkSubtype(expected, t);
if (error) this.error(error);
return error;
}
}

View File

@@ -0,0 +1,9 @@
export class ExpressionParsingError extends Error {
key: string;
message: string;
constructor(key: string, message: string) {
super(message);
this.message = message;
this.key = key;
}
}

View File

@@ -0,0 +1,10 @@
export class RuntimeError extends Error {
constructor(message: string) {
super(message);
this.name = 'RuntimeError';
}
toJSON() {
return this.message;
}
}

View File

@@ -0,0 +1,36 @@
import type {Expression} from './expression';
/**
* Tracks `let` bindings during expression parsing.
* @private
*/
export class Scope {
parent: Scope;
bindings: {[_: string]: Expression};
constructor(parent?: Scope, bindings: Array<[string, Expression]> = []) {
this.parent = parent;
this.bindings = {};
for (const [name, expression] of bindings) {
this.bindings[name] = expression;
}
}
concat(bindings: Array<[string, Expression]>) {
return new Scope(this, bindings);
}
get(name: string): Expression {
if (this.bindings[name]) {
return this.bindings[name];
}
if (this.parent) {
return this.parent.get(name);
}
throw new Error(`${name} not found in scope.`);
}
has(name: string): boolean {
if (this.bindings[name]) return true;
return this.parent ? this.parent.has(name) : false;
}
}

View File

@@ -0,0 +1,22 @@
import {findStopLessThanOrEqualTo} from './stops';
import {describe, test, expect} from 'vitest';
describe('findStopLessThanOrEqualTo', () => {
test('When the input > all stops it returns the last stop.', () => {
const index = findStopLessThanOrEqualTo([0, 1, 2, 3, 4, 5, 6, 7], 8);
expect(index).toBe(7);
});
test('When more than one stop has the same value it always returns the last stop', () => {
let index;
index = findStopLessThanOrEqualTo([0.5, 0.5], 0.5);
expect(index).toBe(1);
index = findStopLessThanOrEqualTo([0.5, 0.5, 0.5], 0.5);
expect(index).toBe(2);
index = findStopLessThanOrEqualTo([0.4, 0.5, 0.5, 0.6, 0.7], 0.5);
expect(index).toBe(2);
});
});

View File

@@ -0,0 +1,38 @@
import {RuntimeError} from './runtime_error';
import type {Expression} from './expression';
export type Stops = Array<[number, Expression]>;
/**
* Returns the index of the last stop <= input, or 0 if it doesn't exist.
* @private
*/
export function findStopLessThanOrEqualTo(stops: Array<number>, input: number) {
const lastIndex = stops.length - 1;
let lowerIndex = 0;
let upperIndex = lastIndex;
let currentIndex = 0;
let currentValue, nextValue;
while (lowerIndex <= upperIndex) {
currentIndex = Math.floor((lowerIndex + upperIndex) / 2);
currentValue = stops[currentIndex];
nextValue = stops[currentIndex + 1];
if (currentValue <= input) {
if (currentIndex === lastIndex || input < nextValue) {
// Search complete
return currentIndex;
}
lowerIndex = currentIndex + 1;
} else if (currentValue > input) {
upperIndex = currentIndex - 1;
} else {
throw new RuntimeError('Input is not a number.');
}
}
return 0;
}

View File

@@ -0,0 +1,211 @@
export type NullTypeT = {
kind: 'null';
};
export type NumberTypeT = {
kind: 'number';
};
export type StringTypeT = {
kind: 'string';
};
export type BooleanTypeT = {
kind: 'boolean';
};
export type ColorTypeT = {
kind: 'color';
};
export type ProjectionDefinitionTypeT = {
kind: 'projectionDefinition';
};
export type ObjectTypeT = {
kind: 'object';
};
export type ValueTypeT = {
kind: 'value';
};
export type ErrorTypeT = {
kind: 'error';
};
export type CollatorTypeT = {
kind: 'collator';
};
export type FormattedTypeT = {
kind: 'formatted';
};
export type PaddingTypeT = {
kind: 'padding';
};
export type NumberArrayTypeT = {
kind: 'numberArray';
};
export type ColorArrayTypeT = {
kind: 'colorArray';
};
export type ResolvedImageTypeT = {
kind: 'resolvedImage';
};
export type VariableAnchorOffsetCollectionTypeT = {
kind: 'variableAnchorOffsetCollection';
};
export type EvaluationKind = 'constant' | 'source' | 'camera' | 'composite';
export type Type =
| NullTypeT
| NumberTypeT
| StringTypeT
| BooleanTypeT
| ColorTypeT
| ProjectionDefinitionTypeT
| ObjectTypeT
| ValueTypeT
| ArrayType
| ErrorTypeT
| CollatorTypeT
| FormattedTypeT
| PaddingTypeT
| NumberArrayTypeT
| ColorArrayTypeT
| ResolvedImageTypeT
| VariableAnchorOffsetCollectionTypeT;
export interface ArrayType<T extends Type = Type> {
kind: 'array';
itemType: T;
N: number;
}
export type NativeType = 'number' | 'string' | 'boolean' | 'null' | 'array' | 'object';
export const NullType = {kind: 'null'} as NullTypeT;
export const NumberType = {kind: 'number'} as NumberTypeT;
export const StringType = {kind: 'string'} as StringTypeT;
export const BooleanType = {kind: 'boolean'} as BooleanTypeT;
export const ColorType = {kind: 'color'} as ColorTypeT;
export const ProjectionDefinitionType = {
kind: 'projectionDefinition'
} as ProjectionDefinitionTypeT;
export const ObjectType = {kind: 'object'} as ObjectTypeT;
export const ValueType = {kind: 'value'} as ValueTypeT;
export const ErrorType = {kind: 'error'} as ErrorTypeT;
export const CollatorType = {kind: 'collator'} as CollatorTypeT;
export const FormattedType = {kind: 'formatted'} as FormattedTypeT;
export const PaddingType = {kind: 'padding'} as PaddingTypeT;
export const ColorArrayType = {kind: 'colorArray'} as ColorArrayTypeT;
export const NumberArrayType = {kind: 'numberArray'} as NumberArrayTypeT;
export const ResolvedImageType = {kind: 'resolvedImage'} as ResolvedImageTypeT;
export const VariableAnchorOffsetCollectionType = {
kind: 'variableAnchorOffsetCollection'
} as VariableAnchorOffsetCollectionTypeT;
export function array<T extends Type>(itemType: T, N?: number | null): ArrayType<T> {
return {
kind: 'array',
itemType,
N
};
}
export function typeToString(type: Type): string {
if (type.kind === 'array') {
const itemType = typeToString(type.itemType);
return typeof type.N === 'number'
? `array<${itemType}, ${type.N}>`
: type.itemType.kind === 'value'
? 'array'
: `array<${itemType}>`;
} else {
return type.kind;
}
}
const valueMemberTypes = [
NullType,
NumberType,
StringType,
BooleanType,
ColorType,
ProjectionDefinitionType,
FormattedType,
ObjectType,
array(ValueType),
PaddingType,
NumberArrayType,
ColorArrayType,
ResolvedImageType,
VariableAnchorOffsetCollectionType
];
/**
* Returns null if `t` is a subtype of `expected`; otherwise returns an
* error message.
* @private
*/
export function checkSubtype(expected: Type, t: Type): string {
if (t.kind === 'error') {
// Error is a subtype of every type
return null;
} else if (expected.kind === 'array') {
if (
t.kind === 'array' &&
((t.N === 0 && t.itemType.kind === 'value') ||
!checkSubtype(expected.itemType, t.itemType)) &&
(typeof expected.N !== 'number' || expected.N === t.N)
) {
return null;
}
} else if (expected.kind === t.kind) {
return null;
} else if (expected.kind === 'value') {
for (const memberType of valueMemberTypes) {
if (!checkSubtype(memberType, t)) {
return null;
}
}
}
return `Expected ${typeToString(expected)} but found ${typeToString(t)} instead.`;
}
export function isValidType(provided: Type, allowedTypes: Array<Type>): boolean {
return allowedTypes.some((t) => t.kind === provided.kind);
}
export function isValidNativeType(provided: any, allowedTypes: Array<NativeType>): boolean {
return allowedTypes.some((t) => {
if (t === 'null') {
return provided === null;
} else if (t === 'array') {
return Array.isArray(provided);
} else if (t === 'object') {
return provided && !Array.isArray(provided) && typeof provided === 'object';
} else {
return t === typeof provided;
}
});
}
/**
* Verify whether the specified type is of the same type as the specified sample.
*
* @param provided Type to verify
* @param sample Sample type to reference
* @returns `true` if both objects are of the same type, `false` otherwise
* @example basic types
* if (verifyType(outputType, ValueType)) {
* // type narrowed to:
* outputType.kind; // 'value'
* }
* @example array types
* if (verifyType(outputType, array(NumberType))) {
* // type narrowed to:
* outputType.kind; // 'array'
* outputType.itemType; // NumberTypeT
* outputType.itemType.kind; // 'number'
* }
*/
export function verifyType<T extends Type>(provided: Type, sample: T): provided is T {
if (provided.kind === 'array' && sample.kind === 'array') {
return provided.itemType.kind === sample.itemType.kind && typeof provided.N === 'number';
}
return provided.kind === sample.kind;
}

View File

@@ -0,0 +1,26 @@
export class Collator {
locale: string | null;
sensitivity: 'base' | 'accent' | 'case' | 'variant';
collator: Intl.Collator;
constructor(caseSensitive: boolean, diacriticSensitive: boolean, locale: string | null) {
if (caseSensitive) this.sensitivity = diacriticSensitive ? 'variant' : 'case';
else this.sensitivity = diacriticSensitive ? 'accent' : 'base';
this.locale = locale;
this.collator = new Intl.Collator(this.locale ? this.locale : [], {
sensitivity: this.sensitivity,
usage: 'search'
});
}
compare(lhs: string, rhs: string): number {
return this.collator.compare(lhs, rhs);
}
resolvedLocale(): string {
// We create a Collator without "usage: search" because we don't want
// the search options encoded in our result (e.g. "en-u-co-search")
return new Intl.Collator(this.locale ? this.locale : []).resolvedOptions().locale;
}
}

View File

@@ -0,0 +1,143 @@
import {expectCloseToArray, expectToMatchColor} from '../../../test/lib/util';
import {Color, isSupportedInterpolationColorSpace} from './color';
import {describe, test, expect} from 'vitest';
describe('Color class', () => {
describe('parsing', () => {
test('should parse valid css color strings', () => {
expectToMatchColor(Color.parse('RED'), 'rgb(100% 0% 0% / 1)');
expectToMatchColor(Color.parse('#f00C'), 'rgb(100% 0% 0% / .8)');
expectToMatchColor(Color.parse('rgb(0 0 127.5 / 20%)'), 'rgb(0% 0% 50% / .2)');
expectToMatchColor(
Color.parse('hsl(300deg 100% 25.1% / 0.7)'),
'rgb(50.2% 0% 50.2% / .7)'
);
});
test('should return undefined when provided with invalid CSS color string', () => {
expect(Color.parse(undefined)).toBeUndefined();
expect(Color.parse(null)).toBeUndefined();
expect(Color.parse('#invalid')).toBeUndefined();
expect(Color.parse('$123')).toBeUndefined();
expect(Color.parse('0F91')).toBeUndefined();
expect(Color.parse('rgb(#123)')).toBeUndefined();
expect(Color.parse('hsl(0,0,0)')).toBeUndefined();
expect(Color.parse('rgb(0deg,0,0)')).toBeUndefined();
});
test('should accept instances of Color class', () => {
const color = new Color(0, 0, 0, 0);
expect(Color.parse(color)).toBe(color);
});
});
test('should keep a reference to the original color when alpha=0', () => {
const color = new Color(0, 0, 0.5, 0, false);
expect(color).toMatchObject({r: 0, g: 0, b: 0, a: 0});
expect(Object.hasOwn(color, 'rgb')).toBe(true);
expectCloseToArray(color.rgb, [0, 0, 0.5, 0]);
});
test('should have static properties, black', () => {
const color = Color.black;
expect(color).toMatchObject({r: 0, g: 0, b: 0, a: 1});
expectCloseToArray(color.rgb, [0, 0, 0, 1]);
});
test('should not keep a reference to the original color when alpha!=0', () => {
const color = new Color(0, 0, 0.5, 0.001, false);
expect(color).toMatchObject({r: 0, g: 0, b: expect.closeTo(0.5 * 0.001, 5), a: 0.001});
expect(Object.hasOwn(color, 'rgb')).toBe(false);
});
test('should serialize to rgba format', () => {
expect(`${new Color(1, 1, 0, 1, false)}`).toBe('rgba(255,255,0,1)');
expect(`${new Color(0.2, 0, 1, 0.3, false)}`).toBe('rgba(51,0,255,0.3)');
expect(`${new Color(1, 1, 0, 0, false)}`).toBe('rgba(255,255,0,0)');
expect(`${Color.parse('purple')}`).toBe('rgba(128,0,128,1)');
expect(`${Color.parse('rgba(26,207,26,.73)')}`).toBe('rgba(26,207,26,0.73)');
expect(`${Color.parse('rgba(26,207,26,0)')}`).toBe('rgba(26,207,26,0)');
});
describe('interpolation color space', () => {
test('should recognize supported interpolation color spaces', () => {
expect(isSupportedInterpolationColorSpace('rgb')).toBe(true);
expect(isSupportedInterpolationColorSpace('hcl')).toBe(true);
expect(isSupportedInterpolationColorSpace('lab')).toBe(true);
});
test('should ignore invalid interpolation color spaces', () => {
expect(isSupportedInterpolationColorSpace('sRGB')).toBe(false);
expect(isSupportedInterpolationColorSpace('HCL')).toBe(false);
expect(isSupportedInterpolationColorSpace('LCH')).toBe(false);
expect(isSupportedInterpolationColorSpace('LAB')).toBe(false);
expect(isSupportedInterpolationColorSpace('interpolate')).toBe(false);
expect(isSupportedInterpolationColorSpace('interpolate-hcl')).toBe(false);
expect(isSupportedInterpolationColorSpace('interpolate-lab')).toBe(false);
});
});
describe('interpolate color', () => {
test('should interpolate colors in "rgb" color space', () => {
const color = Color.parse('rgba(0,0,255,1)');
const targetColor = Color.parse('rgba(0,255,0,.6)');
const i11nFn = (t: number) => Color.interpolate(color, targetColor, t, 'rgb');
expectToMatchColor(i11nFn(0.0), 'rgb(0% 0% 100% / 1)');
expectToMatchColor(i11nFn(0.25), 'rgb(0% 25% 75% / 0.9)');
expectToMatchColor(i11nFn(0.5), 'rgb(0% 50% 50% / 0.8)');
expectToMatchColor(i11nFn(0.75), 'rgb(0% 75% 25% / 0.7)');
expectToMatchColor(i11nFn(1.0), 'rgb(0% 100% 0% / 0.6)');
});
test('should interpolate colors in "hcl" color space', () => {
const color = Color.parse('rgba(0,0,255,1)');
const targetColor = Color.parse('rgba(0,255,0,.6)');
const i11nFn = (t: number) => Color.interpolate(color, targetColor, t, 'hcl');
expectToMatchColor(i11nFn(0.0), 'rgb(0% 0% 100% / 1)');
expectToMatchColor(i11nFn(0.25), 'rgb(0% 49.37% 100% / 0.9)', 4);
expectToMatchColor(i11nFn(0.5), 'rgb(0% 70.44% 100% / 0.8)', 4);
expectToMatchColor(i11nFn(0.75), 'rgb(0% 87.54% 63.18% / 0.7)', 4);
expectToMatchColor(i11nFn(1.0), 'rgb(0% 100% 0% / 0.6)');
});
test('should interpolate colors in "lab" color space', () => {
const color = Color.parse('rgba(0,0,255,1)');
const targetColor = Color.parse('rgba(0,255,0,.6)');
const i11nFn = (t: number) => Color.interpolate(color, targetColor, t, 'lab');
expectToMatchColor(i11nFn(0.0), 'rgb(0% 0% 100% / 1)');
expectToMatchColor(i11nFn(0.25), 'rgb(39.64% 34.55% 83.36% / 0.9)', 4);
expectToMatchColor(i11nFn(0.5), 'rgb(46.42% 56.82% 65.91% / 0.8)', 4);
expectToMatchColor(i11nFn(0.75), 'rgb(41.45% 78.34% 45.62% / 0.7)', 4);
expectToMatchColor(i11nFn(1.0), 'rgb(0% 100% 0% / 0.6)');
});
test('should correctly interpolate colors with alpha=0', () => {
const color = Color.parse('rgba(0,0,255,0)');
const targetColor = Color.parse('rgba(0,255,0,1)');
const i11nFn = (t: number) => Color.interpolate(color, targetColor, t, 'rgb');
expectToMatchColor(i11nFn(0.0), 'rgb(0% 0% 0% / 0)');
expectToMatchColor(i11nFn(0.25), 'rgb(0% 25% 75% / 0.25)');
expectToMatchColor(i11nFn(0.5), 'rgb(0% 50% 50% / 0.5)');
expectToMatchColor(i11nFn(0.75), 'rgb(0% 75% 25% / 0.75)');
expectToMatchColor(i11nFn(1.0), 'rgb(0% 100% 0% / 1)');
});
test('should limit interpolation results to sRGB gamut', () => {
const color = Color.parse('royalblue');
const targetColor = Color.parse('cyan');
for (const space of ['rgb', 'hcl', 'lab'] as const) {
const i11nFn = (t: number) => Color.interpolate(color, targetColor, t, space);
const colorInBetween = i11nFn(0.5);
for (const key of ['r', 'g', 'b', 'a'] as const) {
expect(colorInBetween[key]).toBeGreaterThanOrEqual(0);
expect(colorInBetween[key]).toBeLessThanOrEqual(1);
}
}
});
});
});

View File

@@ -0,0 +1,212 @@
import {HCLColor, hclToRgb, LABColor, labToRgb, RGBColor, rgbToHcl, rgbToLab} from './color_spaces';
import {parseCssColor} from './parse_css_color';
import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives';
export type InterpolationColorSpace = 'rgb' | 'hcl' | 'lab';
/**
* Checks whether the specified color space is one of the supported interpolation color spaces.
*
* @param colorSpace Color space key to verify.
* @returns `true` if the specified color space is one of the supported
* interpolation color spaces, `false` otherwise
*/
export function isSupportedInterpolationColorSpace(
colorSpace: string
): colorSpace is InterpolationColorSpace {
return colorSpace === 'rgb' || colorSpace === 'hcl' || colorSpace === 'lab';
}
/**
* Color representation used by WebGL.
* Defined in sRGB color space and pre-blended with alpha.
* @private
*/
export class Color {
readonly r: number;
readonly g: number;
readonly b: number;
readonly a: number;
/**
* @param r Red component premultiplied by `alpha` 0..1
* @param g Green component premultiplied by `alpha` 0..1
* @param b Blue component premultiplied by `alpha` 0..1
* @param [alpha=1] Alpha component 0..1
* @param [premultiplied=true] Whether the `r`, `g` and `b` values have already
* been multiplied by alpha. If `true` nothing happens if `false` then they will
* be multiplied automatically.
*/
constructor(r: number, g: number, b: number, alpha = 1, premultiplied = true) {
this.r = r;
this.g = g;
this.b = b;
this.a = alpha;
if (!premultiplied) {
this.r *= alpha;
this.g *= alpha;
this.b *= alpha;
if (!alpha) {
// alpha = 0 erases completely rgb channels. This behavior is not desirable
// if this particular color is later used in color interpolation.
// Because of that, a reference to original color is saved.
this.overwriteGetter('rgb', [r, g, b, alpha]);
}
}
}
static black = new Color(0, 0, 0, 1);
static white = new Color(1, 1, 1, 1);
static transparent = new Color(0, 0, 0, 0);
static red = new Color(1, 0, 0, 1);
/**
* Parses CSS color strings and converts colors to sRGB color space if needed.
* Officially supported color formats:
* - keyword, e.g. 'aquamarine' or 'steelblue'
* - hex (with 3, 4, 6 or 8 digits), e.g. '#f0f' or '#e9bebea9'
* - rgb and rgba, e.g. 'rgb(0,240,120)' or 'rgba(0%,94%,47%,0.1)' or 'rgb(0 240 120 / .3)'
* - hsl and hsla, e.g. 'hsl(0,0%,83%)' or 'hsla(0,0%,83%,.5)' or 'hsl(0 0% 83% / 20%)'
*
* @param input CSS color string to parse.
* @returns A `Color` instance, or `undefined` if the input is not a valid color string.
*/
static parse(input: Color | string | undefined | null): Color | undefined {
// in zoom-and-property function input could be an instance of Color class
if (input instanceof Color) {
return input;
}
if (typeof input !== 'string') {
return;
}
const rgba = parseCssColor(input);
if (rgba) {
return new Color(...rgba, false);
}
}
/**
* Used in color interpolation and by 'to-rgba' expression.
*
* @returns Gien color, with reversed alpha blending, in sRGB color space.
*/
get rgb(): RGBColor {
const {r, g, b, a} = this;
const f = a || Infinity; // reverse alpha blending factor
return this.overwriteGetter('rgb', [r / f, g / f, b / f, a]);
}
/**
* Used in color interpolation.
*
* @returns Gien color, with reversed alpha blending, in HCL color space.
*/
get hcl(): HCLColor {
return this.overwriteGetter('hcl', rgbToHcl(this.rgb));
}
/**
* Used in color interpolation.
*
* @returns Gien color, with reversed alpha blending, in LAB color space.
*/
get lab(): LABColor {
return this.overwriteGetter('lab', rgbToLab(this.rgb));
}
/**
* Lazy getter pattern. When getter is called for the first time lazy value
* is calculated and then overwrites getter function in given object instance.
*
* @example:
* const redColor = Color.parse('red');
* let x = redColor.hcl; // this will invoke `get hcl()`, which will calculate
* // the value of red in HCL space and invoke this `overwriteGetter` function
* // which in turn will set a field with a key 'hcl' in the `redColor` object.
* // In other words it will override `get hcl()` from its `Color` prototype
* // with its own property: hcl = [calculated red value in hcl].
* let y = redColor.hcl; // next call will no longer invoke getter but simply
* // return the previously calculated value
* x === y; // true - `x` is exactly the same object as `y`
*
* @param getterKey Getter key
* @param lazyValue Lazily calculated value to be memoized by current instance
* @private
*/
private overwriteGetter<T>(getterKey: string, lazyValue: T): T {
Object.defineProperty(this, getterKey, {value: lazyValue});
return lazyValue;
}
/**
* Used by 'to-string' expression.
*
* @returns Serialized color in format `rgba(r,g,b,a)`
* where r,g,b are numbers within 0..255 and alpha is number within 1..0
*
* @example
* var purple = new Color.parse('purple');
* purple.toString; // = "rgba(128,0,128,1)"
* var translucentGreen = new Color.parse('rgba(26, 207, 26, .73)');
* translucentGreen.toString(); // = "rgba(26,207,26,0.73)"
*/
toString(): string {
const [r, g, b, a] = this.rgb;
return `rgba(${[r, g, b].map((n) => Math.round(n * 255)).join(',')},${a})`;
}
static interpolate(
from: Color,
to: Color,
t: number,
spaceKey: InterpolationColorSpace = 'rgb'
): Color {
switch (spaceKey) {
case 'rgb': {
const [r, g, b, alpha] = interpolateArray(from.rgb, to.rgb, t);
return new Color(r, g, b, alpha, false);
}
case 'hcl': {
const [hue0, chroma0, light0, alphaF] = from.hcl;
const [hue1, chroma1, light1, alphaT] = to.hcl;
// https://github.com/gka/chroma.js/blob/cd1b3c0926c7a85cbdc3b1453b3a94006de91a92/src/interpolator/_hsx.js
let hue, chroma;
if (!isNaN(hue0) && !isNaN(hue1)) {
let dh = hue1 - hue0;
if (hue1 > hue0 && dh > 180) {
dh -= 360;
} else if (hue1 < hue0 && hue0 - hue1 > 180) {
dh += 360;
}
hue = hue0 + t * dh;
} else if (!isNaN(hue0)) {
hue = hue0;
if (light1 === 1 || light1 === 0) chroma = chroma0;
} else if (!isNaN(hue1)) {
hue = hue1;
if (light0 === 1 || light0 === 0) chroma = chroma1;
} else {
hue = NaN;
}
const [r, g, b, alpha] = hclToRgb([
hue,
chroma ?? interpolateNumber(chroma0, chroma1, t),
interpolateNumber(light0, light1, t),
interpolateNumber(alphaF, alphaT, t)
]);
return new Color(r, g, b, alpha, false);
}
case 'lab': {
const [r, g, b, alpha] = labToRgb(interpolateArray(from.lab, to.lab, t));
return new Color(r, g, b, alpha, false);
}
}
}
}

View File

@@ -0,0 +1,48 @@
import {Color} from './color';
import {ColorArray} from './color_array';
import {describe, test, expect} from 'vitest';
describe('ColorArray', () => {
test('ColorArray.parse', () => {
expect(ColorArray.parse()).toBeUndefined();
expect(ColorArray.parse(null)).toBeUndefined();
expect(ColorArray.parse(undefined)).toBeUndefined();
expect(ColorArray.parse('Dennis' as any)).toBeUndefined();
expect(ColorArray.parse('3' as any)).toBeUndefined();
expect(ColorArray.parse('yellow').values).toEqual([Color.parse('yellow')]);
expect(ColorArray.parse([]).values).toEqual([]);
expect(ColorArray.parse(['yellow']).values).toEqual([Color.parse('yellow')]);
expect(ColorArray.parse(['yellow', 'blue']).values).toEqual([
Color.parse('yellow'),
Color.parse('blue')
]);
expect(ColorArray.parse([3, 4] as any)).toBeUndefined();
expect(ColorArray.parse(['non-color', 'words'] as any)).toBeUndefined();
const passThru = new ColorArray([Color.parse('yellow'), Color.parse('blue')]);
expect(ColorArray.parse(passThru)).toBe(passThru);
});
test('ColorArray#toString', () => {
const colorArray = ColorArray.parse(['yellow', 'blue']);
expect(colorArray.toString()).toBe('[{"r":1,"g":1,"b":0,"a":1},{"r":0,"g":0,"b":1,"a":1}]');
});
test('interpolate ColorArray', () => {
const colorArray = ColorArray.parse(['#00A0AA', '#000000']);
const targetColorArray = ColorArray.parse(['#AA0000', '#2468AC']);
const i11nFn = (t: number) => ColorArray.interpolate(colorArray, targetColorArray, t);
expect(i11nFn(0.5)).toBeInstanceOf(ColorArray);
expect(i11nFn(0.5)).toEqual(ColorArray.parse(['#555055', '#123456']));
});
test('interpolate ColorArray with mismatched lengths', () => {
const colorArray = ColorArray.parse(['#00A0AA', '#000000']);
const targetColorArray = ColorArray.parse('#AA0000');
expect(() => {
ColorArray.interpolate(colorArray, targetColorArray, 0.5);
}).toThrowError('colorArray: Arrays have mismatched length (2 vs. 1), cannot interpolate.');
});
});

View File

@@ -0,0 +1,75 @@
import {Color, InterpolationColorSpace} from './color';
/**
* An array of colors. Create instances from
* bare arrays or strings using the static method `ColorArray.parse`.
* @private
*/
export class ColorArray {
values: Color[];
constructor(values: Color[]) {
this.values = values.slice();
}
/**
* ColorArray values
* @param input A ColorArray value
* @returns A `ColorArray` instance, or `undefined` if the input is not a valid ColorArray value.
*/
static parse(input?: string | string[] | ColorArray | null): ColorArray | undefined {
if (input instanceof ColorArray) {
return input;
}
// Backwards compatibility (e.g. hillshade-shadow-color): bare Color is treated the same as array with single value.
if (typeof input === 'string') {
const parsed_val = Color.parse(input);
if (!parsed_val) {
return undefined;
}
return new ColorArray([parsed_val]);
}
if (!Array.isArray(input)) {
return undefined;
}
const colors: Color[] = [];
for (const val of input) {
if (typeof val !== 'string') {
return undefined;
}
const parsed_val = Color.parse(val);
if (!parsed_val) {
return undefined;
}
colors.push(parsed_val);
}
return new ColorArray(colors);
}
toString(): string {
return JSON.stringify(this.values);
}
static interpolate(
from: ColorArray,
to: ColorArray,
t: number,
spaceKey: InterpolationColorSpace = 'rgb'
): ColorArray {
const colors = [] as Color[];
if (from.values.length != to.values.length) {
throw new Error(
`colorArray: Arrays have mismatched length (${from.values.length} vs. ${to.values.length}), cannot interpolate.`
);
}
for (let i = 0; i < from.values.length; i++) {
colors.push(Color.interpolate(from.values[i], to.values[i], t, spaceKey));
}
return new ColorArray(colors);
}
}

View File

@@ -0,0 +1,73 @@
import {expectCloseToArray} from '../../../test/lib/util';
import {hclToRgb, hslToRgb, labToRgb, rgbToHcl, rgbToLab} from './color_spaces';
import {describe, test} from 'vitest';
describe('color spaces', () => {
describe('LAB color space', () => {
test('should convert colors from sRGB to LAB color space', () => {
expectCloseToArray(rgbToLab([0, 0, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(rgbToLab([1, 1, 1, 1]), [100, 0, 0, 1], 4);
expectCloseToArray(rgbToLab([0, 1, 0, 1]), [87.82, -79.29, 80.99, 1], 2);
expectCloseToArray(rgbToLab([0, 1, 1, 1]), [90.67, -50.67, -14.96, 1], 2);
expectCloseToArray(rgbToLab([0, 0, 1, 1]), [29.57, 68.3, -112.03, 1], 2);
expectCloseToArray(rgbToLab([1, 1, 0, 1]), [97.61, -15.75, 93.39, 1], 2);
expectCloseToArray(rgbToLab([1, 0, 0, 1]), [54.29, 80.81, 69.89, 1], 2);
});
test('should convert colors from LAB to sRGB color space', () => {
expectCloseToArray(labToRgb([0, 0, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(labToRgb([100, 0, 0, 1]), [1, 1, 1, 1]);
expectCloseToArray(labToRgb([50, 50, 0, 1]), [0.7562, 0.3045, 0.4756, 1], 4);
expectCloseToArray(labToRgb([70, -45, 0, 1]), [0.1079, 0.7556, 0.664, 1], 4);
expectCloseToArray(labToRgb([70, 0, 70, 1]), [0.7663, 0.6636, 0.0558, 1], 4);
expectCloseToArray(labToRgb([55, 0, -60, 1]), [0.1281, 0.531, 0.9276, 1], 4);
expectCloseToArray(labToRgb([29.57, 68.3, -112.03, 1]), [0, 0, 1, 1], 3);
});
});
describe('HCL color space', () => {
test('should convert colors from sRGB to HCL color space', () => {
expectCloseToArray(rgbToHcl([0, 0, 0, 1]), [NaN, 0, 0, 1]);
expectCloseToArray(rgbToHcl([1, 1, 1, 1]), [NaN, 0, 100, 1], 4);
expectCloseToArray(rgbToHcl([0, 1, 0, 1]), [134.39, 113.34, 87.82, 1], 2);
expectCloseToArray(rgbToHcl([0, 1, 1, 1]), [196.45, 52.83, 90.67, 1], 2);
expectCloseToArray(rgbToHcl([0, 0, 1, 1]), [301.37, 131.21, 29.57, 1], 2);
expectCloseToArray(rgbToHcl([1, 1, 0, 1]), [99.57, 94.71, 97.61, 1], 2);
expectCloseToArray(rgbToHcl([1, 0, 0, 1]), [40.85, 106.84, 54.29, 1], 2);
});
test('should convert colors from HCL to sRGB color space', () => {
expectCloseToArray(hclToRgb([0, 0, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(hclToRgb([0, 0, 100, 1]), [1, 1, 1, 1]);
expectCloseToArray(hclToRgb([0, 50, 50, 1]), [0.7562, 0.3045, 0.4756, 1], 4);
expectCloseToArray(hclToRgb([180, 45, 70, 1]), [0.1079, 0.7556, 0.664, 1], 4);
expectCloseToArray(hclToRgb([90, 70, 70, 1]), [0.7663, 0.6636, 0.0558, 1], 4);
expectCloseToArray(hclToRgb([270, 60, 55, 1]), [0.1281, 0.531, 0.9276, 1], 4);
expectCloseToArray(hclToRgb([301.37, 131.21, 29.57, 1]), [0, 0, 1, 1], 3);
});
});
describe('HSL color space', () => {
test('should convert colors from HSL to sRGB color space', () => {
expectCloseToArray(hslToRgb([0, 0, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(hslToRgb([0, 100, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(hslToRgb([0, 0, 100, 1]), [1, 1, 1, 1]);
expectCloseToArray(hslToRgb([360, 0, 0, 1]), [0, 0, 0, 1]);
expectCloseToArray(hslToRgb([120, 100, 25, 1]), [0, 128 / 255, 0, 1], 2);
expectCloseToArray(hslToRgb([120, 30, 50, 0]), [89 / 255, 166 / 255, 89 / 255, 0], 2);
expectCloseToArray(
hslToRgb([240, 25, 50, 0.1]),
[96 / 255, 96 / 255, 159 / 255, 0.1],
2
);
expectCloseToArray(
hslToRgb([240, 50, 50, 0.8]),
[64 / 255, 64 / 255, 191 / 255, 0.8],
2
);
expectCloseToArray(hslToRgb([270, 75, 75, 1]), [191 / 255, 143 / 255, 239 / 255, 1], 2);
expectCloseToArray(hslToRgb([300, 100, 50, 0.5]), [1, 0, 1, 0.5]);
expectCloseToArray(hslToRgb([330, 0, 25, 0.3]), [64 / 255, 64 / 255, 64 / 255, 0.3], 2);
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* @param r Red component 0..1
* @param g Green component 0..1
* @param b Blue component 0..1
* @param alpha Alpha component 0..1
*/
export type RGBColor = [r: number, g: number, b: number, alpha: number];
/**
* @param h Hue as degrees 0..360
* @param s Saturation as percentage 0..100
* @param l Lightness as percentage 0..100
* @param alpha Alpha component 0..1
*/
export type HSLColor = [h: number, s: number, l: number, alpha: number];
/**
* @param h Hue as degrees 0..360
* @param c Chroma 0..~230
* @param l Lightness as percentage 0..100
* @param alpha Alpha component 0..1
*/
export type HCLColor = [h: number, c: number, l: number, alpha: number];
/**
* @param l Lightness as percentage 0..100
* @param a A axis value -125..125
* @param b B axis value -125..125
* @param alpha Alpha component 0..1
*/
export type LABColor = [l: number, a: number, b: number, alpha: number];
// See https://observablehq.com/@mbostock/lab-and-rgb
const Xn = 0.96422,
Yn = 1,
Zn = 0.82521,
t0 = 4 / 29,
t1 = 6 / 29,
t2 = 3 * t1 * t1,
t3 = t1 * t1 * t1,
deg2rad = Math.PI / 180,
rad2deg = 180 / Math.PI;
function constrainAngle(angle: number): number {
angle = angle % 360;
if (angle < 0) {
angle += 360;
}
return angle;
}
export function rgbToLab([r, g, b, alpha]: RGBColor): LABColor {
r = rgb2xyz(r);
g = rgb2xyz(g);
b = rgb2xyz(b);
let x, z;
const y = xyz2lab((0.2225045 * r + 0.7168786 * g + 0.0606169 * b) / Yn);
if (r === g && g === b) {
x = z = y;
} else {
x = xyz2lab((0.4360747 * r + 0.3850649 * g + 0.1430804 * b) / Xn);
z = xyz2lab((0.0139322 * r + 0.0971045 * g + 0.7141733 * b) / Zn);
}
const l = 116 * y - 16;
return [l < 0 ? 0 : l, 500 * (x - y), 200 * (y - z), alpha];
}
function rgb2xyz(x: number): number {
return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
}
function xyz2lab(t: number): number {
return t > t3 ? Math.pow(t, 1 / 3) : t / t2 + t0;
}
export function labToRgb([l, a, b, alpha]: LABColor): RGBColor {
let y = (l + 16) / 116,
x = isNaN(a) ? y : y + a / 500,
z = isNaN(b) ? y : y - b / 200;
y = Yn * lab2xyz(y);
x = Xn * lab2xyz(x);
z = Zn * lab2xyz(z);
return [
xyz2rgb(3.1338561 * x - 1.6168667 * y - 0.4906146 * z), // D50 -> sRGB
xyz2rgb(-0.9787684 * x + 1.9161415 * y + 0.033454 * z),
xyz2rgb(0.0719453 * x - 0.2289914 * y + 1.4052427 * z),
alpha
];
}
function xyz2rgb(x: number): number {
x = x <= 0.00304 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
return x < 0 ? 0 : x > 1 ? 1 : x; // clip to 0..1 range
}
function lab2xyz(t: number): number {
return t > t1 ? t * t * t : t2 * (t - t0);
}
export function rgbToHcl(rgbColor: RGBColor): HCLColor {
const [l, a, b, alpha] = rgbToLab(rgbColor);
const c = Math.sqrt(a * a + b * b);
const h = Math.round(c * 10000) ? constrainAngle(Math.atan2(b, a) * rad2deg) : NaN;
return [h, c, l, alpha];
}
export function hclToRgb([h, c, l, alpha]: HCLColor): RGBColor {
h = isNaN(h) ? 0 : h * deg2rad;
return labToRgb([l, Math.cos(h) * c, Math.sin(h) * c, alpha]);
}
// https://drafts.csswg.org/css-color-4/#hsl-to-rgb
export function hslToRgb([h, s, l, alpha]: HSLColor): RGBColor {
h = constrainAngle(h);
s /= 100;
l /= 100;
function f(n) {
const k = (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
}
return [f(0), f(8), f(4), alpha];
}

View File

@@ -0,0 +1,63 @@
import type {Color} from '../../expression/types/color';
import type {ResolvedImage} from '../types/resolved_image';
export const VERTICAL_ALIGN_OPTIONS = ['bottom', 'center', 'top'] as const;
export type VerticalAlign = (typeof VERTICAL_ALIGN_OPTIONS)[number];
export class FormattedSection {
text: string;
image: ResolvedImage | null;
scale: number | null;
fontStack: string | null;
textColor: Color | null;
verticalAlign: VerticalAlign | null;
constructor(
text: string,
image: ResolvedImage | null,
scale: number | null,
fontStack: string | null,
textColor: Color | null,
verticalAlign: VerticalAlign | null
) {
this.text = text;
this.image = image;
this.scale = scale;
this.fontStack = fontStack;
this.textColor = textColor;
this.verticalAlign = verticalAlign;
}
}
export class Formatted {
sections: Array<FormattedSection>;
constructor(sections: Array<FormattedSection>) {
this.sections = sections;
}
static fromString(unformatted: string): Formatted {
return new Formatted([new FormattedSection(unformatted, null, null, null, null, null)]);
}
isEmpty(): boolean {
if (this.sections.length === 0) return true;
return !this.sections.some(
(section) =>
section.text.length !== 0 || (section.image && section.image.name.length !== 0)
);
}
static factory(text: Formatted | string): Formatted {
if (text instanceof Formatted) {
return text;
} else {
return Formatted.fromString(text);
}
}
toString(): string {
if (this.sections.length === 0) return '';
return this.sections.map((section) => section.text).join('');
}
}

View File

@@ -0,0 +1,36 @@
import {NumberArray} from './number_array';
import {describe, test, expect} from 'vitest';
describe('NumberArray', () => {
test('NumberArray.parse', () => {
expect(NumberArray.parse()).toBeUndefined();
expect(NumberArray.parse(null)).toBeUndefined();
expect(NumberArray.parse(undefined)).toBeUndefined();
expect(NumberArray.parse('Dennis' as any)).toBeUndefined();
expect(NumberArray.parse('3' as any)).toBeUndefined();
expect(NumberArray.parse([3, '4'] as any)).toBeUndefined();
expect(NumberArray.parse(5).values).toEqual([5]);
expect(NumberArray.parse([]).values).toEqual([]);
expect(NumberArray.parse([1]).values).toEqual([1]);
expect(NumberArray.parse([1, 2]).values).toEqual([1, 2]);
expect(NumberArray.parse([1, 2, 3]).values).toEqual([1, 2, 3]);
expect(NumberArray.parse([1, 2, 3, 4]).values).toEqual([1, 2, 3, 4]);
expect(NumberArray.parse([1, 2, 3, 4, 5]).values).toEqual([1, 2, 3, 4, 5]);
const passThru = new NumberArray([1, 2, 3, 4]);
expect(NumberArray.parse(passThru)).toBe(passThru);
});
test('NumberArray#toString', () => {
const numberArray = new NumberArray([1, 2, 3, 4]);
expect(numberArray.toString()).toBe('[1,2,3,4]');
});
test('interpolate NumberArray', () => {
const numberArray = new NumberArray([0, 0, 0, 0]);
const targetNumberArray = new NumberArray([1, 2, 6, 4]);
const i11nFn = (t: number) => NumberArray.interpolate(numberArray, targetNumberArray, t);
expect(i11nFn(0.5)).toBeInstanceOf(NumberArray);
expect(i11nFn(0.5).values).toEqual([0.5, 1, 3, 2]);
});
});

View File

@@ -0,0 +1,50 @@
import {interpolateArray} from '../../util/interpolate-primitives';
/**
* An array of numbers. Create instances from
* bare arrays or numeric values using the static method `NumberArray.parse`.
* @private
*/
export class NumberArray {
values: number[];
constructor(values: number[]) {
this.values = values.slice();
}
/**
* Numeric NumberArray values
* @param input A NumberArray value
* @returns A `NumberArray` instance, or `undefined` if the input is not a valid NumberArray value.
*/
static parse(input?: number | number[] | NumberArray | null): NumberArray | undefined {
if (input instanceof NumberArray) {
return input;
}
// Backwards compatibility (e.g. hillshade-illumination-direction): bare number is treated the same as array with single value.
if (typeof input === 'number') {
return new NumberArray([input]);
}
if (!Array.isArray(input)) {
return undefined;
}
for (const val of input) {
if (typeof val !== 'number') {
return undefined;
}
}
return new NumberArray(input);
}
toString(): string {
return JSON.stringify(this.values);
}
static interpolate(from: NumberArray, to: NumberArray, t: number): NumberArray {
return new NumberArray(interpolateArray(from.values, to.values, t));
}
}

View File

@@ -0,0 +1,36 @@
import {Padding} from './padding';
import {describe, test, expect} from 'vitest';
describe('Padding', () => {
test('Padding.parse', () => {
expect(Padding.parse()).toBeUndefined();
expect(Padding.parse(null)).toBeUndefined();
expect(Padding.parse(undefined)).toBeUndefined();
expect(Padding.parse('Dennis' as any)).toBeUndefined();
expect(Padding.parse('3' as any)).toBeUndefined();
expect(Padding.parse([])).toBeUndefined();
expect(Padding.parse([3, '4'] as any)).toBeUndefined();
expect(Padding.parse(5)).toEqual(new Padding([5, 5, 5, 5]));
expect(Padding.parse([1])).toEqual(new Padding([1, 1, 1, 1]));
expect(Padding.parse([1, 2])).toEqual(new Padding([1, 2, 1, 2]));
expect(Padding.parse([1, 2, 3])).toEqual(new Padding([1, 2, 3, 2]));
expect(Padding.parse([1, 2, 3, 4])).toEqual(new Padding([1, 2, 3, 4]));
expect(Padding.parse([1, 2, 3, 4, 5])).toBeUndefined();
const passThru = new Padding([1, 2, 3, 4]);
expect(Padding.parse(passThru)).toBe(passThru);
});
test('Padding#toString', () => {
const padding = new Padding([1, 2, 3, 4]);
expect(padding.toString()).toBe('[1,2,3,4]');
});
test('interpolate padding', () => {
const padding = new Padding([0, 0, 0, 0]);
const targetPadding = new Padding([1, 2, 6, 4]);
const i11nFn = (t: number) => Padding.interpolate(padding, targetPadding, t);
expect(i11nFn(0.5)).toBeInstanceOf(Padding);
expect(i11nFn(0.5)).toEqual(new Padding([0.5, 1, 3, 2]));
});
});

View File

@@ -0,0 +1,69 @@
import {interpolateArray} from '../../util/interpolate-primitives';
/**
* A set of four numbers representing padding around a box. Create instances from
* bare arrays or numeric values using the static method `Padding.parse`.
* @private
*/
export class Padding {
/** Padding values are in CSS order: top, right, bottom, left */
values: [number, number, number, number];
constructor(values: [number, number, number, number]) {
this.values = values.slice() as [number, number, number, number];
}
/**
* Numeric padding values
* @param input A padding value
* @returns A `Padding` instance, or `undefined` if the input is not a valid padding value.
*/
static parse(input?: number | number[] | Padding | null): Padding | undefined {
if (input instanceof Padding) {
return input;
}
// Backwards compatibility: bare number is treated the same as array with single value.
// Padding applies to all four sides.
if (typeof input === 'number') {
return new Padding([input, input, input, input]);
}
if (!Array.isArray(input)) {
return undefined;
}
if (input.length < 1 || input.length > 4) {
return undefined;
}
for (const val of input) {
if (typeof val !== 'number') {
return undefined;
}
}
// Expand shortcut properties into explicit 4-sided values
switch (input.length) {
case 1:
input = [input[0], input[0], input[0], input[0]];
break;
case 2:
input = [input[0], input[1], input[0], input[1]];
break;
case 3:
input = [input[0], input[1], input[2], input[1]];
break;
}
return new Padding(input as [number, number, number, number]);
}
toString(): string {
return JSON.stringify(this.values);
}
static interpolate(from: Padding, to: Padding, t: number): Padding {
return new Padding(interpolateArray(from.values, to.values, t));
}
}

View File

@@ -0,0 +1,369 @@
import {parseCssColor} from './parse_css_color';
import * as colorSpacesModule from './color_spaces';
import {RGBColor} from './color_spaces';
import {describe, test, expect, afterEach, vi} from 'vitest';
describe('parseCssColor', () => {
// by changing the parse function, we can verify external css color parsers against our requirements
const parse: (colorToParse: string) => RGBColor | undefined = parseCssColor;
describe('color keywords', () => {
test('should parse valid color names', () => {
expect(parse('white')).toEqual([1, 1, 1, 1]);
expect(parse('black')).toEqual([0, 0, 0, 1]);
expect(parse('RED')).toEqual([1, 0, 0, 1]);
expect(parse('AquaMarine')).toEqual([127 / 255, 255 / 255, 212 / 255, 1]);
expect(parse('steelblue')).toEqual([70 / 255, 130 / 255, 180 / 255, 1]);
expect(parse('rebeccapurple')).toEqual([0.4, 0.2, 0.6, 1]);
});
test('should parse "transparent" keyword as transparent black', () => {
expect(parse('transparent')).toEqual([0, 0, 0, 0]);
expect(parse('Transparent')).toEqual([0, 0, 0, 0]);
expect(parse('TRANSPARENT')).toEqual([0, 0, 0, 0]);
});
test('should return undefined when provided with invalid color name', () => {
expect(parse('not a color name')).toBeUndefined();
expect(parse('')).toBeUndefined();
expect(parse('blak')).toBeUndefined();
expect(parse('aqua-marine')).toBeUndefined();
expect(parse('aqua_marine')).toBeUndefined();
expect(parse('aqua marine')).toBeUndefined();
expect(parse('__proto__')).toBeUndefined();
expect(parse('valueOf')).toBeUndefined();
});
});
describe('RGB hexadecimal notations', () => {
test('should parse valid rgb hex values', () => {
// hex 3
expect(parse('#fff')).toEqual([1, 1, 1, 1]);
expect(parse('#000')).toEqual([0, 0, 0, 1]);
expect(parse('#369')).toEqual([0.2, 0.4, 0.6, 1]);
// hex 4
expect(parse('#ffff')).toEqual([1, 1, 1, 1]);
expect(parse('#fff0')).toEqual([1, 1, 1, 0]);
expect(parse('#0000')).toEqual([0, 0, 0, 0]);
expect(parse('#FFFC')).toEqual([1, 1, 1, 0.8]);
expect(parse('#234a')).toEqual([34 / 255, 51 / 255, 68 / 255, 2 / 3]);
// hex 6
expect(parse('#ffffff')).toEqual([1, 1, 1, 1]);
expect(parse('#000000')).toEqual([0, 0, 0, 1]);
expect(parse('#008000')).toEqual([0, 128 / 255, 0, 1]);
expect(parse('#b96710')).toEqual([185 / 255, 103 / 255, 16 / 255, 1]);
// hex 8
expect(parse('#ffffffff')).toEqual([1, 1, 1, 1]);
expect(parse('#000000ff')).toEqual([0, 0, 0, 1]);
expect(parse('#00000000')).toEqual([0, 0, 0, 0]);
expect(parse('#FFCc9933')).toEqual([255 / 255, 204 / 255, 153 / 255, 0.2]);
expect(parse('#4682B466')).toEqual([70 / 255, 130 / 255, 180 / 255, 0.4]);
});
test('should return undefined when provided with invalid rgb hex value', () => {
expect(parse('#')).toBeUndefined();
expect(parse('#f')).toBeUndefined();
expect(parse('#ff')).toBeUndefined();
expect(parse('#ffg')).toBeUndefined();
expect(parse('#fffg')).toBeUndefined();
expect(parse('#fffff')).toBeUndefined();
expect(parse('#fffffg')).toBeUndefined();
expect(parse('#fffffff')).toBeUndefined();
expect(parse('#fffffffg')).toBeUndefined();
expect(parse('#fffffffff')).toBeUndefined();
expect(parse('fff')).toBeUndefined();
expect(parse('# fff')).toBeUndefined();
});
});
describe('RGB functions "rgb()" and "rgba()"', () => {
test('should parse valid rgb values', () => {
// rgb 0..255
expect(parse('rgb(0 51 0)')).toEqual([0, 0.2, 0, 1]);
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb(0, 51, 0)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb(0.0, 51.0, +0.0)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgba(0, 51, 0)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgba(0, 51, 0, 1)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgba(0, 51, 0, 100%)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgba( 0, 51, 0, 100% )'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgba( 00 ,51 ,0 ,100% )'));
expect(parse('rgb(0 51 0)')).toEqual(parse(' rgb(.0 51 0 / 1)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb(0.0 51.0 0.0 / 1.0) '));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb(0 51 0 / 1.0)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('RGB(0 51 0 / 100%)'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb( 0 51 0/1 )'));
expect(parse('rgb(0 51 0)')).toEqual(parse('rgb(0 5.1e+1 0 / .1e1)'));
expect(parse('rgb(0,0.5,1)')).toEqual([0, 0.5 / 255, 1 / 255, 1]);
expect(parse('rgb(0,0.5,1)')).toEqual(parse('rgb(0 0.5 1)'));
expect(parse('rgb(0,0,0,.1e-4)')).toEqual([0, 0, 0, 1e-5]);
expect(parse('rgb(102,51,153)')).toEqual([0.4, 0.2, 0.6, 1]);
expect(parse('rgb(26,207,26,0.5)')).toEqual([26 / 255, 207 / 255, 26 / 255, 0.5]);
expect(parse('rgba(26,207,26,.73)')).toEqual([26 / 255, 207 / 255, 26 / 255, 0.73]);
expect(parse('rgb(127.5 0 0)')).toEqual([0.5, 0, 0, 1]);
expect(parse('rgb(128 0 0)')).toEqual([128 / 255, 0, 0, 1]);
expect(parse('rgb(100 200 300)')).toEqual([100 / 255, 200 / 255, 1, 1]);
expect(parse('rgb(-0 255 153)')).toEqual([0, 1, 0.6, 1]);
expect(parse('rgb(-100 300 153)')).toEqual([0, 1, 0.6, 1]);
expect(parse('rgb(-51, 306, 0)')).toEqual([0, 1, 0, 1]);
expect(parse('rgba(0,0,0,0.1)')).toEqual([0, 0, 0, 0.1]);
expect(parse('rgba(0,0,0,0.1)')).toEqual(parse('rgb(0 0 0 / .1)'));
expect(parse('rgba(0,0,0,0.1)')).toEqual(parse('rgb(0 0 0 / 10%)'));
expect(parse('rgb(0 0 0 / .0)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0 0 0 / -.0)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0 0 0 / -3.4e-2)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0 0 0 / -.2)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0 0 0 / -10%)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0 0 0 / 1.0)')).toEqual([0, 0, 0, 1]);
expect(parse('rgb(0 0 0 / 1.1)')).toEqual([0, 0, 0, 1]);
expect(parse('rgb(0 0 0 / 110%)')).toEqual([0, 0, 0, 1]);
// rgb 0%..100%
expect(parse('rgb(0% 50% 0%)')).toEqual([0, 0.5, 0, 1]);
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb(0%, 50%, 0%)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgba(0%, 50%, 0%, 1)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgba(0%, 50%, 0%, 100%)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgba(-0e1%,5E1%,0%,1)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb(0% 50% 0% / 1.0)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb(.0% 50% 0% / 100%)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb(0.0% 50.0% 0.0% / 1)'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb( 0% 50% 0% / 100% )'));
expect(parse('rgb(0% 50% 0%)')).toEqual(parse('rgb(-1e-9% 50% 0% /1)'));
expect(parse('rgb(-0% 100% 60%)')).toEqual([0, 1, 0.6, 1]);
expect(parse('rgb(-10% 200% 60%)')).toEqual([0, 1, 0.6, 1]);
expect(parse('rgb(100%,200%,300%)')).toEqual([1, 1, 1, 1]);
expect(parse('rgb(128% 51% 255%)')).toEqual([1, 0.51, 1, 1]);
expect(parse('rgba(0%,0%,0%,0.1)')).toEqual([0, 0, 0, 0.1]);
expect(parse('rgba(0%,0%,0%,0.1)')).toEqual(parse('rgb(0% 0% 0% / .1)'));
expect(parse('rgba(0%,0%,0%,0.1)')).toEqual(parse('rgb(0% 0% 0% / 10%)'));
expect(parse('rgb(0% 0% 0% / .0)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0% 0% 0% / -.0)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0% 0% 0% / -3.4e-2)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0% 0% 0% / -.2)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0% 0% 0% / -10%)')).toEqual([0, 0, 0, 0]);
expect(parse('rgb(0% 0% 0% / 1.0)')).toEqual([0, 0, 0, 1]);
expect(parse('rgb(0% 0% 0% / 1.1)')).toEqual([0, 0, 0, 1]);
expect(parse('rgb(0% 0% 0% / 110%)')).toEqual([0, 0, 0, 1]);
});
test('should return undefined when provided with invalid rgb value', () => {
expect(parse('rgb (0,0,0)')).toBeUndefined();
expect(parse('rgba (0,0,0,0)')).toBeUndefined();
const invalidArgs = [
'10%, 50%, 0', // values must be all numbers or all percentages
'255, 50%, 0%',
'10%, 50%, 0, 1',
'255, 50%, 0%, 1',
'0 50% 255 / 1',
'0 50% 0 / 1',
'128 51% 255',
'0, 0 0', // comma optional syntax requires no commas at all
'0, 0, 0 0',
'0, 0, 0 / 1',
'0 0 0, 1',
'0, 0, 0deg', // angles are not accepted in the rgb function
'0, 0, 0, 0deg',
'0, 0, light', // keywords are not accepted in the rgb function
'0, 0, 0, light',
'--1,0,0', // invalid numbers
'+-1,0,0',
'++1,0,0',
'1.1.1,0,0',
'.-1,0,0',
'..1,0,0',
'1e1.1,0,0',
'1e.1,0,0',
'--1e1,0,0',
'+-1e1,0,0',
'', // the rgb function requires 3 or 4 arguments
'0',
', 0,',
'0, 0',
'0, 0,',
', 0, 0',
'0 0 0 /',
'0, 0, 0, 0, 0',
'0, 0, 0, 0, 0,',
', 0, 0, 0, 0, 0',
'0%',
', 0%,',
'0%, 0%',
'0%, 0%,',
', 0%, 0%',
'0%, 0%, 0%,',
'0% 0% 0% /',
'0%, 0%, 0%, 0%, 0%',
'0%, 50%, 100%,',
', 0%, 50%, 100%',
', 0%, 50%, 100%, 100%',
'0%, 50%, 100%, 100%,'
];
for (const args of invalidArgs) {
for (const fn of ['rgb', 'rgba']) {
const input = `${fn}(${args})`;
try {
expect(parse(input)).toBeUndefined();
} catch (error) {
error.message = `\nInput: ${input}\n${error.message}`;
throw error;
}
}
}
});
});
describe('HSL functions "hsl()" and "hsla()"', () => {
afterEach(() => {
vi.resetAllMocks();
});
test('should parse valid hsl values', () => {
vi.spyOn(colorSpacesModule, 'hslToRgb').mockImplementation((hslColor) => hslColor);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual([300, 100, 25.1, 1]);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsla(300,100%,25.1%,1)')
);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsla(300,100%,25.1%,100%)')
);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsl(300 100% 25.1%)')
);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsl(300 100% 25.1%/1.0)')
);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsl(300.0 100% 25.1% / 100%)')
);
expect(parseCssColor('hsl(300,100%,25.1%)')).toEqual(
parseCssColor('hsl(300deg 100% 25.1% / 100%)')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual([240, 0, 55, 0.2]);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsla(240.0,0%,55%,0.2)')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsla( 240 ,.0% ,55.0% ,20% )')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsl(240 0% 55% / 0.2)')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsl(240 0% 55% / 20%)')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsl(24e1deg 0e1% 55% / 2e-1)')
);
expect(parseCssColor('hsl(240,0%,55%,0.2)')).toEqual(
parseCssColor('hsla(240 -1e-7% 55% / 2e1%)')
);
expect(parseCssColor('hsl(240,0%,55%,0.9)')).toEqual([240, 0, 55, 0.9]);
expect(parseCssColor('hsl(240,0%,55%,.0)')).toEqual([240, 0, 55, 0]);
expect(parseCssColor('hsl(700 0% 67.3% / 100%)')).toEqual([700, 0, 67.3, 1]);
expect(parseCssColor('Hsl( -100 -10.5% 67.3% / 100% )')).toEqual([-100, 0, 67.3, 1]);
});
test('should parse valid hsl values and convert to rgb', () => {
expect(parse('hsl(0 100% 50%)')).toEqual([1, 0, 0, 1]);
expect(parse('hsl(240 100% 50%)')).toEqual([0, 0, 1, 1]);
expect(parse('hsl(240 100% 25%)')).toEqual([0, 0, 0.5, 1]);
expect(parse('hsl(273 75% 60%)')).toEqual([
expect.closeTo(0.63),
expect.closeTo(0.3),
0.9,
1
]);
expect(parse('hsl(0 0% 0%)')).toEqual([0, 0, 0, 1]);
expect(parse('hsl(0 0% 0% / 0)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / 0)')).toEqual(parse('hsl(0,0%,0%,+0)'));
expect(parse('hsl(0 0% 0% / 0)')).toEqual(parse('hsla(0deg,0%,0%,-0)'));
expect(parse('hsl(0 0% 0% / 0)')).toEqual(parse('hsla(0,0%,0%,0%)'));
expect(parse('hsl(0 0% 0% / 0)')).toEqual(parse(' hsla(.0,.0%,.0%,.0%)'));
expect(parse('hsl(0 0% 0% / 0)')).toEqual(parse('hsla( 0 ,0% ,0% ,.0 ) '));
expect(parse('hsl(120 100% 25%)')).toEqual([0, 0.5, 0, 1]);
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120.0 100.0% 25.0%)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120deg 100% 25%)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120 100% 25% / 1.0)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120deg 100% 25% / 1)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120 100% 25% / 100%)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsl(120,100%,25%,100%)'));
expect(parse('hsl(120 100% 25%)')).toEqual(parse('hsla(120deg,100%,25%,100%)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual([0, 1, 0, 0.25]);
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('HSLA(120,100%,50%,.25)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('hsla(120,100%,50%,25%)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('hsl(120 100% 50%/.25)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('hsl(120deg 100% 50% / 25%)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('hsl(480 100% 50% / 25%)'));
expect(parse('hsl(120 100% 50% / .25)')).toEqual(parse('hsl(-240deg 100% 50% / 25%)'));
expect(parse('hsl(0.0 200% 50%)')).toEqual(parse('hsl(0 100% 50%)'));
expect(parse('hsl(-0 -100% -100%)')).toEqual(parse('hsl(0 0% 0%)'));
expect(parse('hsl(0 0% 0% / .0)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / -.0)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / -3.4e-2)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / -.2)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / -10%)')).toEqual([0, 0, 0, 0]);
expect(parse('hsl(0 0% 0% / 1.0)')).toEqual([0, 0, 0, 1]);
expect(parse('hsl(0 0% 0% / 1.1)')).toEqual([0, 0, 0, 1]);
expect(parse('hsl(0 0% 0% / 110%)')).toEqual([0, 0, 0, 1]);
});
test('should return undefined when provided with invalid hsl value', () => {
expect(parse('hsl (0,0%,0%)')).toBeUndefined();
expect(parse('hsla (0,0%,0%,1)')).toBeUndefined();
const invalidArgs = [
'0,0%,0 %',
'0%,0%,0%', // the first parameter of hsl/hsla must be a number or angle
'0 deg,0%,0%',
'10, 50%, 0', // the second and third parameters of hsl/hsla must be a percent
'0, 0% 0%', // comma optional syntax requires no commas at all
'0, 0% 0%, 1',
'0,0%,light,1', // keywords are not accepted in the hsl function
'--1,0%,0%', // invalid numbers
'+-1,0%,0%',
'++1,0%,0%',
'1.1.1,0%,0%',
'.-1,0%,0%',
'..1,0%,0%',
'1e1.1,0%,0%',
'1e.1,0%,0%',
'--1e1,0%,0%',
'+-1e1,0%,0%',
'', // The hsl function requires 3 or 4 arguments
'0',
'0 0%',
'0, 0%,',
', 0%, 0%',
'0,0%,0%,1,0%',
'0,0,0',
'0,0%,0',
'0,0,0%',
'0 0% 0% /',
'0,0%,0%,',
', 0%,0%,0%'
];
for (const args of invalidArgs) {
for (const fn of ['hsl', 'hsla']) {
const input = `${fn}(${args})`;
try {
expect(parse(input)).toBeUndefined();
} catch (error) {
error.message = `\nInput: ${input}\n${error.message}`;
throw error;
}
}
}
});
});
});

View File

@@ -0,0 +1,329 @@
import {getOwn} from '../../util/get_own';
import {HSLColor, hslToRgb, RGBColor} from './color_spaces';
/**
* CSS color parser compliant with CSS Color 4 Specification.
* Supports: named colors, `transparent` keyword, all rgb hex notations,
* rgb(), rgba(), hsl() and hsla() functions.
* Does not round the parsed values to integers from the range 0..255.
*
* Syntax:
*
* <alpha-value> = <number> | <percentage>
* <hue> = <number> | <angle>
*
* rgb() = rgb( <percentage>{3} [ / <alpha-value> ]? ) | rgb( <number>{3} [ / <alpha-value> ]? )
* rgb() = rgb( <percentage>#{3} , <alpha-value>? ) | rgb( <number>#{3} , <alpha-value>? )
*
* hsl() = hsl( <hue> <percentage> <percentage> [ / <alpha-value> ]? )
* hsl() = hsl( <hue>, <percentage>, <percentage>, <alpha-value>? )
*
* Caveats:
* - <angle> - <number> with optional `deg` suffix; `grad`, `rad`, `turn` are not supported
* - `none` keyword is not supported
* - comments inside rgb()/hsl() are not supported
* - legacy color syntax rgba() is supported with an identical grammar and behavior to rgb()
* - legacy color syntax hsla() is supported with an identical grammar and behavior to hsl()
*
* @param input CSS color string to parse.
* @returns Color in sRGB color space, with `red`, `green`, `blue`
* and `alpha` channels normalized to the range 0..1,
* or `undefined` if the input is not a valid color string.
*/
export function parseCssColor(input: string): RGBColor | undefined {
input = input.toLowerCase().trim();
if (input === 'transparent') {
return [0, 0, 0, 0];
}
// 'white', 'black', 'blue'
const namedColorsMatch = getOwn(namedColors, input);
if (namedColorsMatch) {
const [r, g, b] = namedColorsMatch;
return [r / 255, g / 255, b / 255, 1];
}
// #f0c, #f0cf, #ff00cc, #ff00ccff
if (input.startsWith('#')) {
const hexRegexp = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/;
if (hexRegexp.test(input)) {
const step = input.length < 6 ? 1 : 2;
let i = 1;
return [
parseHex(input.slice(i, (i += step))),
parseHex(input.slice(i, (i += step))),
parseHex(input.slice(i, (i += step))),
parseHex(input.slice(i, i + step) || 'ff')
];
}
}
// rgb(128 0 0), rgb(50% 0% 0%), rgba(255,0,255,0.6), rgb(255 0 255 / 60%), rgb(100% 0% 100% /.6)
if (input.startsWith('rgb')) {
const rgbRegExp =
/^rgba?\(\s*([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/;
const rgbMatch = input.match(rgbRegExp);
if (rgbMatch) {
const [
_, // eslint-disable-line @typescript-eslint/no-unused-vars
r, // <numeric>
rp, // % (optional)
f1, // , (optional)
g, // <numeric>
gp, // % (optional)
f2, // , (optional)
b, // <numeric>
bp, // % (optional)
f3, // ,|/ (optional)
a, // <numeric> (optional)
ap // % (optional)
] = rgbMatch;
const argFormat = [f1 || ' ', f2 || ' ', f3].join('');
if (
argFormat === ' ' ||
argFormat === ' /' ||
argFormat === ',,' ||
argFormat === ',,,'
) {
const valFormat = [rp, gp, bp].join('');
const maxValue = valFormat === '%%%' ? 100 : valFormat === '' ? 255 : 0;
if (maxValue) {
const rgba: RGBColor = [
clamp(+r / maxValue, 0, 1),
clamp(+g / maxValue, 0, 1),
clamp(+b / maxValue, 0, 1),
a ? parseAlpha(+a, ap) : 1
];
if (validateNumbers(rgba)) {
return rgba;
}
// invalid numbers
}
// values must be all numbers or all percentages
}
return; // comma optional syntax requires no commas at all
}
}
// hsl(120 50% 80%), hsla(120deg,50%,80%,.9), hsl(12e1 50% 80% / 90%)
const hslRegExp =
/^hsla?\(\s*([\de.+-]+)(?:deg)?(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/;
const hslMatch = input.match(hslRegExp);
if (hslMatch) {
const [
_, // eslint-disable-line @typescript-eslint/no-unused-vars
h, // <numeric>
f1, // , (optional)
s, // <numeric>
f2, // , (optional)
l, // <numeric>
f3, // ,|/ (optional)
a, // <numeric> (optional)
ap // % (optional)
] = hslMatch;
const argFormat = [f1 || ' ', f2 || ' ', f3].join('');
if (
argFormat === ' ' ||
argFormat === ' /' ||
argFormat === ',,' ||
argFormat === ',,,'
) {
const hsla: HSLColor = [
+h,
clamp(+s, 0, 100),
clamp(+l, 0, 100),
a ? parseAlpha(+a, ap) : 1
];
if (validateNumbers(hsla)) {
return hslToRgb(hsla);
}
// invalid numbers
}
// comma optional syntax requires no commas at all
}
}
function parseHex(hex: string): number {
return parseInt(hex.padEnd(2, hex), 16) / 255;
}
function parseAlpha(a: number, asPercentage: string | undefined): number {
return clamp(asPercentage ? a / 100 : a, 0, 1);
}
function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(min, n), max);
}
/**
* The regular expression for numeric values is not super specific, and it may
* happen that it will accept a value that is not a valid number. In order to
* detect and eliminate such values this function exists.
*
* @param array Array of uncertain numbers.
* @returns `true` if the specified array contains only valid numbers, `false` otherwise.
*/
function validateNumbers(array: number[]): boolean {
return !array.some(Number.isNaN);
}
/**
* To generate:
* - visit {@link https://www.w3.org/TR/css-color-4/#named-colors}
* - run in the console:
* @example
* copy(`{\n${[...document.querySelector('.named-color-table tbody').children].map((tr) => `${tr.cells[2].textContent.trim()}: [${tr.cells[4].textContent.trim().split(/\s+/).join(', ')}],`).join('\n')}\n}`);
*/
const namedColors: Record<string, [number, number, number]> = {
aliceblue: [240, 248, 255],
antiquewhite: [250, 235, 215],
aqua: [0, 255, 255],
aquamarine: [127, 255, 212],
azure: [240, 255, 255],
beige: [245, 245, 220],
bisque: [255, 228, 196],
black: [0, 0, 0],
blanchedalmond: [255, 235, 205],
blue: [0, 0, 255],
blueviolet: [138, 43, 226],
brown: [165, 42, 42],
burlywood: [222, 184, 135],
cadetblue: [95, 158, 160],
chartreuse: [127, 255, 0],
chocolate: [210, 105, 30],
coral: [255, 127, 80],
cornflowerblue: [100, 149, 237],
cornsilk: [255, 248, 220],
crimson: [220, 20, 60],
cyan: [0, 255, 255],
darkblue: [0, 0, 139],
darkcyan: [0, 139, 139],
darkgoldenrod: [184, 134, 11],
darkgray: [169, 169, 169],
darkgreen: [0, 100, 0],
darkgrey: [169, 169, 169],
darkkhaki: [189, 183, 107],
darkmagenta: [139, 0, 139],
darkolivegreen: [85, 107, 47],
darkorange: [255, 140, 0],
darkorchid: [153, 50, 204],
darkred: [139, 0, 0],
darksalmon: [233, 150, 122],
darkseagreen: [143, 188, 143],
darkslateblue: [72, 61, 139],
darkslategray: [47, 79, 79],
darkslategrey: [47, 79, 79],
darkturquoise: [0, 206, 209],
darkviolet: [148, 0, 211],
deeppink: [255, 20, 147],
deepskyblue: [0, 191, 255],
dimgray: [105, 105, 105],
dimgrey: [105, 105, 105],
dodgerblue: [30, 144, 255],
firebrick: [178, 34, 34],
floralwhite: [255, 250, 240],
forestgreen: [34, 139, 34],
fuchsia: [255, 0, 255],
gainsboro: [220, 220, 220],
ghostwhite: [248, 248, 255],
gold: [255, 215, 0],
goldenrod: [218, 165, 32],
gray: [128, 128, 128],
green: [0, 128, 0],
greenyellow: [173, 255, 47],
grey: [128, 128, 128],
honeydew: [240, 255, 240],
hotpink: [255, 105, 180],
indianred: [205, 92, 92],
indigo: [75, 0, 130],
ivory: [255, 255, 240],
khaki: [240, 230, 140],
lavender: [230, 230, 250],
lavenderblush: [255, 240, 245],
lawngreen: [124, 252, 0],
lemonchiffon: [255, 250, 205],
lightblue: [173, 216, 230],
lightcoral: [240, 128, 128],
lightcyan: [224, 255, 255],
lightgoldenrodyellow: [250, 250, 210],
lightgray: [211, 211, 211],
lightgreen: [144, 238, 144],
lightgrey: [211, 211, 211],
lightpink: [255, 182, 193],
lightsalmon: [255, 160, 122],
lightseagreen: [32, 178, 170],
lightskyblue: [135, 206, 250],
lightslategray: [119, 136, 153],
lightslategrey: [119, 136, 153],
lightsteelblue: [176, 196, 222],
lightyellow: [255, 255, 224],
lime: [0, 255, 0],
limegreen: [50, 205, 50],
linen: [250, 240, 230],
magenta: [255, 0, 255],
maroon: [128, 0, 0],
mediumaquamarine: [102, 205, 170],
mediumblue: [0, 0, 205],
mediumorchid: [186, 85, 211],
mediumpurple: [147, 112, 219],
mediumseagreen: [60, 179, 113],
mediumslateblue: [123, 104, 238],
mediumspringgreen: [0, 250, 154],
mediumturquoise: [72, 209, 204],
mediumvioletred: [199, 21, 133],
midnightblue: [25, 25, 112],
mintcream: [245, 255, 250],
mistyrose: [255, 228, 225],
moccasin: [255, 228, 181],
navajowhite: [255, 222, 173],
navy: [0, 0, 128],
oldlace: [253, 245, 230],
olive: [128, 128, 0],
olivedrab: [107, 142, 35],
orange: [255, 165, 0],
orangered: [255, 69, 0],
orchid: [218, 112, 214],
palegoldenrod: [238, 232, 170],
palegreen: [152, 251, 152],
paleturquoise: [175, 238, 238],
palevioletred: [219, 112, 147],
papayawhip: [255, 239, 213],
peachpuff: [255, 218, 185],
peru: [205, 133, 63],
pink: [255, 192, 203],
plum: [221, 160, 221],
powderblue: [176, 224, 230],
purple: [128, 0, 128],
rebeccapurple: [102, 51, 153],
red: [255, 0, 0],
rosybrown: [188, 143, 143],
royalblue: [65, 105, 225],
saddlebrown: [139, 69, 19],
salmon: [250, 128, 114],
sandybrown: [244, 164, 96],
seagreen: [46, 139, 87],
seashell: [255, 245, 238],
sienna: [160, 82, 45],
silver: [192, 192, 192],
skyblue: [135, 206, 235],
slateblue: [106, 90, 205],
slategray: [112, 128, 144],
slategrey: [112, 128, 144],
snow: [255, 250, 250],
springgreen: [0, 255, 127],
steelblue: [70, 130, 180],
tan: [210, 180, 140],
teal: [0, 128, 128],
thistle: [216, 191, 216],
tomato: [255, 99, 71],
turquoise: [64, 224, 208],
violet: [238, 130, 238],
wheat: [245, 222, 179],
white: [255, 255, 255],
whitesmoke: [245, 245, 245],
yellow: [255, 255, 0],
yellowgreen: [154, 205, 50]
};

View File

@@ -0,0 +1,62 @@
import {ProjectionDefinition} from './projection_definition';
import {describe, test, expect} from 'vitest';
describe('Projection class', () => {
test('should parse projection with multiple inputs', () => {
const projection = ProjectionDefinition.parse(['mercator', 'vertical-perspective', 0.5]);
expect(projection.from).toBe('mercator');
expect(projection.to).toBe('vertical-perspective');
expect(projection.transition).toBe(0.5);
});
test('should parse projection with single input', () => {
const projection = ProjectionDefinition.parse('mercator');
expect(projection.from).toBe('mercator');
expect(projection.to).toBe('mercator');
expect(projection.transition).toBe(1);
});
test('should return undefined when input is not an array or string', () => {
const projection = ProjectionDefinition.parse({} as any);
expect(projection).toBeUndefined();
});
test('should return undefined when input is an array with length not equal to 3', () => {
const projection = ProjectionDefinition.parse(['mercator', 'vertical-perspective'] as any);
expect(projection).toBeUndefined();
});
test('should return undefined when input is an array with non-string and non-number elements', () => {
const projection = ProjectionDefinition.parse([1, 2, 3] as any);
expect(projection).toBeUndefined();
});
test('should interpolate projections', () => {
const projection = ProjectionDefinition.interpolate(
'mercator',
'vertical-perspective',
0.5
);
expect(projection.from).toBe('mercator');
expect(projection.to).toBe('vertical-perspective');
expect(projection.transition).toBe(0.5);
});
test('should parse projection object', () => {
const projection = ProjectionDefinition.parse({
from: 'mercator',
to: 'vertical-perspective',
transition: 0.5
});
expect(projection.from).toBe('mercator');
expect(projection.to).toBe('vertical-perspective');
expect(projection.transition).toBe(0.5);
});
test('should serialize projection', () => {
const projection = ProjectionDefinition.parse(['mercator', 'vertical-perspective', 0.5]);
expect(JSON.stringify(projection)).toBe(
'{\"from\":\"mercator\",\"to\":\"vertical-perspective\",\"transition\":0.5}'
);
});
});

View File

@@ -0,0 +1,44 @@
export class ProjectionDefinition {
readonly from: string;
readonly to: string;
readonly transition: number;
constructor(from: string, to: string, transition: number) {
this.from = from;
this.to = to;
this.transition = transition;
}
static interpolate(from: string, to: string, t: number) {
return new ProjectionDefinition(from, to, t);
}
static parse(input?: any): ProjectionDefinition {
if (input instanceof ProjectionDefinition) {
return input;
}
if (
Array.isArray(input) &&
input.length === 3 &&
typeof input[0] === 'string' &&
typeof input[1] === 'string' &&
typeof input[2] === 'number'
) {
return new ProjectionDefinition(input[0], input[1], input[2]);
}
if (
typeof input === 'object' &&
typeof input.from === 'string' &&
typeof input.to === 'string' &&
typeof input.transition === 'number'
) {
return new ProjectionDefinition(input.from, input.to, input.transition);
}
if (typeof input === 'string') {
return new ProjectionDefinition(input, input, 1);
}
return undefined;
}
}

View File

@@ -0,0 +1,23 @@
export type ResolvedImageOptions = {
name: string;
available: boolean;
};
export class ResolvedImage {
name: string;
available: boolean;
constructor(options: ResolvedImageOptions) {
this.name = options.name;
this.available = options.available;
}
toString(): string {
return this.name;
}
static fromString(name: string): ResolvedImage | null {
if (!name) return null; // treat empty values as no image
return new ResolvedImage({name, available: false});
}
}

View File

@@ -0,0 +1,62 @@
import {VariableAnchorOffsetCollection} from './variable_anchor_offset_collection';
import {describe, test, expect} from 'vitest';
describe('VariableAnchorOffsetCollection', () => {
test('VariableAnchorOffsetCollection.parse', () => {
expect(VariableAnchorOffsetCollection.parse()).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(null)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(undefined)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse('Dennis' as any)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(3 as any)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse({} as any)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse([])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['Dennis'])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['top'])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['top', 'bottom'])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['top', 3] as any)).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['Dennis', [2, 2]])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['top', [2, 2]])).toEqual(
new VariableAnchorOffsetCollection(['top', [2, 2]])
);
expect(VariableAnchorOffsetCollection.parse(['top', [2, 2], 'bottom'])).toBeUndefined();
expect(VariableAnchorOffsetCollection.parse(['top', [2, 2], 'bottom', [3, 3]])).toEqual(
new VariableAnchorOffsetCollection(['top', [2, 2], 'bottom', [3, 3]])
);
const identity = new VariableAnchorOffsetCollection(['top', [2, 2]]);
expect(VariableAnchorOffsetCollection.parse(identity)).toBe(identity);
});
test('VariableAnchorOffsetCollection#toString', () => {
const coll = new VariableAnchorOffsetCollection(['top', [2, 2]]);
expect(coll.toString()).toBe('["top",[2,2]]');
});
describe('interpolate variableAnchorOffsetCollection', () => {
const i11nFn = VariableAnchorOffsetCollection.interpolate;
const parseFn = VariableAnchorOffsetCollection.parse;
test('should throw with mismatched endpoints', () => {
expect(() =>
i11nFn(parseFn(['top', [0, 0]]), parseFn(['bottom', [1, 1]]), 0.5)
).toThrowError(
'Cannot interpolate values containing mismatched anchors. from[0]: top, to[0]: bottom'
);
expect(() =>
i11nFn(parseFn(['top', [0, 0]]), parseFn(['top', [1, 1], 'bottom', [2, 2]]), 0.5)
).toThrowError(
'Cannot interpolate values of different length. from: ["top",[0,0]], to: ["top",[1,1],"bottom",[2,2]]'
);
});
test('should interpolate offsets', () => {
expect(
i11nFn(
parseFn(['top', [0, 0], 'bottom', [2, 2]]),
parseFn(['top', [1, 1], 'bottom', [4, 4]]),
0.5
).values
).toEqual(['top', [0.5, 0.5], 'bottom', [3, 3]]);
});
});
});

View File

@@ -0,0 +1,101 @@
import {RuntimeError} from '../runtime_error';
import {interpolateNumber} from '../../util/interpolate-primitives';
import type {VariableAnchorOffsetCollectionSpecification} from '../../types.g';
/** Set of valid anchor positions, as a set for validation */
const anchors = new Set([
'center',
'left',
'right',
'top',
'bottom',
'top-left',
'top-right',
'bottom-left',
'bottom-right'
]);
/**
* Utility class to assist managing values for text-variable-anchor-offset property. Create instances from
* bare arrays using the static method `VariableAnchorOffsetCollection.parse`.
* @private
*/
export class VariableAnchorOffsetCollection {
/** Series of paired of anchor (string) and offset (point) values */
values: VariableAnchorOffsetCollectionSpecification;
constructor(values: VariableAnchorOffsetCollectionSpecification) {
this.values = values.slice();
}
static parse(
input?: VariableAnchorOffsetCollectionSpecification | VariableAnchorOffsetCollection
): VariableAnchorOffsetCollection | undefined {
if (input instanceof VariableAnchorOffsetCollection) {
return input;
}
if (!Array.isArray(input) || input.length < 1 || input.length % 2 !== 0) {
return undefined;
}
for (let i = 0; i < input.length; i += 2) {
// Elements in even positions should be anchor positions; Elements in odd positions should be offset values
const anchorValue = input[i];
const offsetValue = input[i + 1];
if (typeof anchorValue !== 'string' || !anchors.has(anchorValue)) {
return undefined;
}
if (
!Array.isArray(offsetValue) ||
offsetValue.length !== 2 ||
typeof offsetValue[0] !== 'number' ||
typeof offsetValue[1] !== 'number'
) {
return undefined;
}
}
return new VariableAnchorOffsetCollection(input);
}
toString(): string {
return JSON.stringify(this.values);
}
static interpolate(
from: VariableAnchorOffsetCollection,
to: VariableAnchorOffsetCollection,
t: number
): VariableAnchorOffsetCollection {
const fromValues = from.values;
const toValues = to.values;
if (fromValues.length !== toValues.length) {
throw new RuntimeError(
`Cannot interpolate values of different length. from: ${from.toString()}, to: ${to.toString()}`
);
}
const output: VariableAnchorOffsetCollectionSpecification = [];
for (let i = 0; i < fromValues.length; i += 2) {
// Anchor entries must match
if (fromValues[i] !== toValues[i]) {
throw new RuntimeError(
`Cannot interpolate values containing mismatched anchors. from[${i}]: ${fromValues[i]}, to[${i}]: ${toValues[i]}`
);
}
output.push(fromValues[i]);
// Interpolate the offset values for each anchor
const [fx, fy] = fromValues[i + 1] as [number, number];
const [tx, ty] = toValues[i + 1] as [number, number];
output.push([interpolateNumber(fx, tx, t), interpolateNumber(fy, ty, t)]);
}
return new VariableAnchorOffsetCollection(output);
}
}

View File

@@ -0,0 +1,35 @@
import {
CollatorType,
ColorArrayType,
ColorType,
FormattedType,
NumberArrayType,
PaddingType,
ProjectionDefinitionType,
VariableAnchorOffsetCollectionType
} from './types';
import {Collator} from './types/collator';
import {Color} from './types/color';
import {ColorArray} from './types/color_array';
import {Formatted} from './types/formatted';
import {NumberArray} from './types/number_array';
import {Padding} from './types/padding';
import {ProjectionDefinition} from './types/projection_definition';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {typeOf} from './values';
import {describe, test, expect} from 'vitest';
describe('typeOf', () => {
test('typeOf', () => {
expect(typeOf(Color.red)).toBe(ColorType);
expect(typeOf(ProjectionDefinition.parse('mercator'))).toBe(ProjectionDefinitionType);
expect(typeOf(new Collator(false, false, null))).toBe(CollatorType);
expect(typeOf(Formatted.factory('a'))).toBe(FormattedType);
expect(typeOf(Padding.parse(1))).toBe(PaddingType);
expect(typeOf(NumberArray.parse(1))).toBe(NumberArrayType);
expect(typeOf(ColorArray.parse('red'))).toBe(ColorArrayType);
expect(typeOf(VariableAnchorOffsetCollection.parse(['top', [2, 2]]))).toBe(
VariableAnchorOffsetCollectionType
);
});
});

View File

@@ -0,0 +1,180 @@
import {Color} from './types/color';
import {Collator} from './types/collator';
import {Formatted} from './types/formatted';
import {Padding} from './types/padding';
import {NumberArray} from './types/number_array';
import {ColorArray} from './types/color_array';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {ResolvedImage} from './types/resolved_image';
import {ProjectionDefinition} from './types/projection_definition';
import {
NullType,
NumberType,
StringType,
BooleanType,
ColorType,
ObjectType,
ValueType,
CollatorType,
FormattedType,
ResolvedImageType,
array,
PaddingType,
NumberArrayType,
ColorArrayType,
VariableAnchorOffsetCollectionType,
ProjectionDefinitionType
} from './types';
import type {Type} from './types';
export function validateRGBA(r: unknown, g: unknown, b: unknown, a?: unknown): string | null {
if (
!(
typeof r === 'number' &&
r >= 0 &&
r <= 255 &&
typeof g === 'number' &&
g >= 0 &&
g <= 255 &&
typeof b === 'number' &&
b >= 0 &&
b <= 255
)
) {
const value = typeof a === 'number' ? [r, g, b, a] : [r, g, b];
return `Invalid rgba value [${value.join(', ')}]: 'r', 'g', and 'b' must be between 0 and 255.`;
}
if (!(typeof a === 'undefined' || (typeof a === 'number' && a >= 0 && a <= 1))) {
return `Invalid rgba value [${[r, g, b, a].join(', ')}]: 'a' must be between 0 and 1.`;
}
return null;
}
export type Value =
| null
| string
| boolean
| number
| Color
| ProjectionDefinition
| Collator
| Formatted
| Padding
| NumberArray
| ColorArray
| ResolvedImage
| VariableAnchorOffsetCollection
| ReadonlyArray<Value>
| {
readonly [x: string]: Value;
};
export function isValue(mixed: unknown): boolean {
if (
mixed === null ||
typeof mixed === 'string' ||
typeof mixed === 'boolean' ||
typeof mixed === 'number' ||
mixed instanceof ProjectionDefinition ||
mixed instanceof Color ||
mixed instanceof Collator ||
mixed instanceof Formatted ||
mixed instanceof Padding ||
mixed instanceof NumberArray ||
mixed instanceof ColorArray ||
mixed instanceof VariableAnchorOffsetCollection ||
mixed instanceof ResolvedImage
) {
return true;
} else if (Array.isArray(mixed)) {
for (const item of mixed) {
if (!isValue(item)) {
return false;
}
}
return true;
} else if (typeof mixed === 'object') {
for (const key in mixed) {
if (!isValue(mixed[key])) {
return false;
}
}
return true;
} else {
return false;
}
}
export function typeOf(value: Value): Type {
if (value === null) {
return NullType;
} else if (typeof value === 'string') {
return StringType;
} else if (typeof value === 'boolean') {
return BooleanType;
} else if (typeof value === 'number') {
return NumberType;
} else if (value instanceof Color) {
return ColorType;
} else if (value instanceof ProjectionDefinition) {
return ProjectionDefinitionType;
} else if (value instanceof Collator) {
return CollatorType;
} else if (value instanceof Formatted) {
return FormattedType;
} else if (value instanceof Padding) {
return PaddingType;
} else if (value instanceof NumberArray) {
return NumberArrayType;
} else if (value instanceof ColorArray) {
return ColorArrayType;
} else if (value instanceof VariableAnchorOffsetCollection) {
return VariableAnchorOffsetCollectionType;
} else if (value instanceof ResolvedImage) {
return ResolvedImageType;
} else if (Array.isArray(value)) {
const length = value.length;
let itemType: Type | typeof undefined;
for (const item of value) {
const t = typeOf(item);
if (!itemType) {
itemType = t;
} else if (itemType === t) {
continue;
} else {
itemType = ValueType;
break;
}
}
return array(itemType || ValueType, length);
} else {
return ObjectType;
}
}
export function valueToString(value: Value) {
const type = typeof value;
if (value === null) {
return '';
} else if (type === 'string' || type === 'number' || type === 'boolean') {
return String(value);
} else if (
value instanceof Color ||
value instanceof ProjectionDefinition ||
value instanceof Formatted ||
value instanceof Padding ||
value instanceof NumberArray ||
value instanceof ColorArray ||
value instanceof VariableAnchorOffsetCollection ||
value instanceof ResolvedImage
) {
return value.toString();
} else {
return JSON.stringify(value);
}
}

View File

@@ -0,0 +1,140 @@
import createVisibilityExpression from './visibility';
import {describe, test, expect, vi} from 'vitest';
describe('create visibility expression', () => {
test('throws Error for invalid function', () => {
expect(() => createVisibilityExpression(['bla'] as any, {})).toThrowError(
'Unknown expression "bla". If you wanted a literal array, use ["literal", [...]].'
);
});
});
describe('evaluate visibility expression', () => {
test('literal value none', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const value = createVisibilityExpression('none', {});
expect(value.evaluate()).toBe('none');
expect(value.getGlobalStateRefs().size).toBe(0);
expect(console.warn).not.toHaveBeenCalled();
});
test('literal value visible', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const value = createVisibilityExpression('visible', {});
expect(value.evaluate()).toBe('visible');
expect(value.getGlobalStateRefs().size).toBe(0);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state property set to none', () => {
const globalState: Record<string, any> = {};
const value = createVisibilityExpression(['global-state', 'x'], globalState);
vi.spyOn(console, 'warn').mockImplementation(() => {});
globalState.x = 'none';
expect(value.evaluate()).toBe('none');
expect(value.getGlobalStateRefs().has('x')).toBe(true);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state property set to visible', () => {
const globalState: Record<string, any> = {};
const value = createVisibilityExpression(['global-state', 'x'], globalState);
vi.spyOn(console, 'warn').mockImplementation(() => {});
globalState.x = 'visible';
expect(value.evaluate()).toBe('visible');
expect(value.getGlobalStateRefs().has('x')).toBe(true);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state flag set to false', () => {
const globalState: Record<string, any> = {};
const value = createVisibilityExpression(
['case', ['global-state', 'x'], 'visible', 'none'],
globalState
);
vi.spyOn(console, 'warn').mockImplementation(() => {});
globalState.x = false;
expect(value.evaluate()).toBe('none');
expect(value.getGlobalStateRefs().has('x')).toBe(true);
expect(console.warn).not.toHaveBeenCalled();
});
test('global state flag set to true', () => {
const globalState: Record<string, any> = {};
const value = createVisibilityExpression(
['case', ['global-state', 'x'], 'visible', 'none'],
globalState
);
vi.spyOn(console, 'warn').mockImplementation(() => {});
globalState.x = true;
expect(value.evaluate()).toBe('visible');
expect(value.getGlobalStateRefs().has('x')).toBe(true);
expect(console.warn).not.toHaveBeenCalled();
});
test('falls back to default for invalid expression with zoom', () => {
const value = createVisibilityExpression(
['case', ['==', ['zoom'], 5], 'none', 'visible'],
{}
);
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate()).toBe('visible');
expect(console.warn).not.toHaveBeenCalled();
});
test('warns and falls back to default for invalid expression with feature', () => {
const value = createVisibilityExpression(['get', 'x'], {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate()).toBe('visible');
expect(console.warn).toHaveBeenCalledWith(
'Expected value to be of type string, but found null instead.'
);
});
test('warns and falls back to default for invalid expression with feature state', () => {
const value = createVisibilityExpression(['feature-state', 'x'], {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate()).toBe('visible');
expect(console.warn).toHaveBeenCalledWith(
'Expected value to be of type string, but found null instead.'
);
});
test('warns and falls back to default for missing global property', () => {
const value = createVisibilityExpression(['global-state', 'x'], {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate()).toBe('visible');
expect(console.warn).toHaveBeenCalledWith(
'Expected value to be of type string, but found null instead.'
);
});
test('warns and falls back to default for invalid global property', () => {
const value = createVisibilityExpression(['global-state', 'x'], {x: 'invalid'});
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(value.evaluate()).toBe('visible');
expect(console.warn).toHaveBeenCalledWith(
'Expected value to be one of "visible", "none", but found "invalid" instead.'
);
});
});

View File

@@ -0,0 +1,86 @@
import {createExpression, findGlobalStateRefs} from './index';
import {StyleExpression, type GlobalProperties, type StylePropertySpecification} from '../index';
import {type VisibilitySpecification} from '../types.g';
const visibilitySpec: StylePropertySpecification = {
type: 'enum',
'property-type': 'data-constant',
expression: {
interpolated: false,
parameters: ['global-state']
},
values: {visible: {}, none: {}},
transition: false,
default: 'visible'
};
export interface VisibilityExpression {
/**
* Evaluates the visibility expression and returns either 'visible' or 'none'.
*/
evaluate: () => 'visible' | 'none';
/**
* Updates the visibility specification.
*/
setValue: (visibility: VisibilitySpecification) => void;
/**
* Returns the set of global state properties referenced by the expression.
*/
getGlobalStateRefs: () => Set<string>;
}
class VisibilityExpressionClass implements VisibilityExpression {
private _globalState: Record<string, any>;
private _globalStateRefs: Set<string>;
private _literalValue: 'visible' | 'none' | undefined;
private _compiledValue: StyleExpression;
constructor(visibility: VisibilitySpecification, globalState: Record<string, any>) {
this._globalState = globalState;
this.setValue(visibility);
}
evaluate(): 'visible' | 'none' {
return this._literalValue ?? this._compiledValue.evaluate({} as GlobalProperties);
}
setValue(visibility: VisibilitySpecification) {
if (
visibility === null ||
visibility === undefined ||
visibility === 'visible' ||
visibility === 'none'
) {
this._literalValue = visibility === 'none' ? 'none' : 'visible';
this._compiledValue = undefined;
this._globalStateRefs = new Set<string>();
return;
}
const compiled = createExpression(visibility, visibilitySpec, this._globalState);
if (compiled.result === 'error') {
this._literalValue = 'visible';
this._compiledValue = undefined;
throw new Error(compiled.value.map((err) => `${err.key}: ${err.message}`).join(', '));
}
this._literalValue = undefined;
this._compiledValue = compiled.value;
this._globalStateRefs = findGlobalStateRefs(compiled.value.expression);
}
getGlobalStateRefs() {
return this._globalStateRefs;
}
}
/**
* Creates a visibility expression from a visibility specification.
* @param visibility - the visibility specification, literal or expression
* @param globalState - the global state object
* @returns visibility expression object
*/
export default function createVisibility(
visibility: VisibilitySpecification,
globalState: Record<string, any>
): VisibilityExpression {
return new VisibilityExpressionClass(visibility, globalState);
}

View File

@@ -0,0 +1,55 @@
## Filter
Filter expressions are used to target specific data in a layer. This library implements the semantics specified by the [MapLibre GL JS spec](https://maplibre.org/maplibre-gl-js-docs/style-spec/).
### API
`featureFilter(filter)`
Given a filter expressed as nested arrays, return a new function
that evaluates whether a given feature (with a .properties or .tags property)
passes its test.
#### Parameters
| parameter | type | description |
| --------- | ----- | ---------------- |
| `filter` | Array | MapLibre filter |
**Returns** `Function`, filter-evaluating function
### Usage
``` javascript
var ff = require('@maplibre/maplibre-gl-style-spec').featureFilter;
// will match a feature with class of street_limited,
// AND an admin_level less than or equal to 3,
// that's NOT a polygon.
var filter = [
"all",
["==", "class", "street_limited"],
["<=", "admin_level", 3],
["!=", "$type", "Polygon"]
]
// will match a feature that has a class of
// wetland OR wetland_noveg.
// ["in", "class", "wetland", "wetland_noveg"]
// testFilter will be a function that returns a boolean
var testFilter = ff(filter);
// Layer feature that you're testing. Must have type
// and properties keys.
var feature = {
type: 2,
properties: {
class: "street_limited",
admin_level: 1
}
};
// will return a boolean based on whether the feature matched the filter
return testFilter({zoom: 0}, feature);
```

View File

@@ -0,0 +1,226 @@
import {isExpressionFilter} from './index';
import type {
ExpressionFilterSpecification,
ExpressionInputType,
ExpressionSpecification,
FilterSpecification,
LegacyFilterSpecification
} from '../types.g';
type ExpectedTypes = {[_: string]: ExpressionInputType};
/*
* Convert the given filter to an expression, storing the expected types for
* any feature properties referenced in expectedTypes.
*
* These expected types are needed in order to construct preflight type checks
* needed for handling 'any' filters. A preflight type check is necessary in
* order to mimic legacy filters' semantics around expected type mismatches.
* For example, consider the legacy filter:
*
* ["any", ["all", [">", "y", 0], [">", "y", 0]], [">", "x", 0]]
*
* Naively, we might convert this to the expression:
*
* ["any", ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]], [">", ["get", "x"], 0]]
*
* But if we tried to evaluate this against, say `{x: 1, y: null, z: 0}`, the
* [">", ["get", "y"], 0] would cause an evaluation error, leading to the
* entire filter returning false. Legacy filter semantics, though, ask for
* [">", "y", 0] to simply return `false` when `y` is of the wrong type,
* allowing the subsequent terms of the outer "any" expression to be evaluated
* (resulting, in this case, in a `true` value, because x > 0).
*
* We account for this by inserting a preflight type-checking expression before
* each "any" term, allowing us to avoid evaluating the actual converted filter
* if any type mismatches would cause it to produce an evaluation error:
*
* ["any",
* ["case",
* ["all", ["==", ["typeof", ["get", "y"]], "number"], ["==", ["typeof", ["get", "z"], "number]],
* ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]],
* false
* ],
* ["case",
* ["==", ["typeof", ["get", "x"], "number"]],
* [">", ["get", "x"], 0],
* false
* ]
* ]
*
* An alternative, possibly more direct approach would be to use type checks
* in the conversion of each comparison operator, so that the converted version
* of each individual ==, >=, etc. would mimic the legacy filter semantics. The
* downside of this approach is that it can lead to many more type checks than
* would otherwise be necessary: outside the context of an "any" expression,
* bailing out due to a runtime type error (expression semantics) and returning
* false (legacy filter semantics) are equivalent: they cause the filter to
* produce a `false` result.
*/
export function convertFilter(
filter: FilterSpecification,
expectedTypes: ExpectedTypes = {}
): ExpressionFilterSpecification {
if (isExpressionFilter(filter)) return filter;
if (!filter) return true;
const legacyFilter = filter as LegacyFilterSpecification;
const legacyOp = legacyFilter[0];
if (filter.length <= 1) return legacyOp !== 'any';
switch (legacyOp) {
case '==':
case '!=':
case '<':
case '>':
case '<=':
case '>=': {
const [, property, value] = filter;
return convertComparisonOp(property as string, value, legacyOp, expectedTypes);
}
case 'any': {
const [, ...conditions] = legacyFilter;
const children = conditions.map((f: LegacyFilterSpecification) => {
const types = {};
const child = convertFilter(f, types);
const typechecks = runtimeTypeChecks(types);
return typechecks === true
? child
: (['case', typechecks, child, false] as ExpressionSpecification);
});
return ['any', ...children];
}
case 'all': {
const [, ...conditions] = legacyFilter;
const children = conditions.map((f) => convertFilter(f, expectedTypes));
return children.length > 1 ? ['all', ...children] : children[0];
}
case 'none': {
const [, ...conditions] = legacyFilter;
return ['!', convertFilter(['any', ...conditions], {})];
}
case 'in': {
const [, property, ...values] = legacyFilter;
return convertInOp(property, values);
}
case '!in': {
const [, property, ...values] = legacyFilter;
return convertInOp(property, values, true);
}
case 'has':
return convertHasOp(legacyFilter[1]);
case '!has':
return ['!', convertHasOp(legacyFilter[1])];
default:
return true;
}
}
// Given a set of feature properties and an expected type for each one,
// construct an boolean expression that tests whether each property has the
// right type.
// E.g.: for {name: 'string', population: 'number'}, return
// [ 'all',
// ['==', ['typeof', ['get', 'name'], 'string']],
// ['==', ['typeof', ['get', 'population'], 'number]]
// ]
function runtimeTypeChecks(expectedTypes: ExpectedTypes): ExpressionFilterSpecification {
const conditions = [];
for (const property in expectedTypes) {
const get = property === '$id' ? ['id'] : ['get', property];
conditions.push(['==', ['typeof', get], expectedTypes[property]]);
}
if (conditions.length === 0) return true;
if (conditions.length === 1) return conditions[0];
return ['all', ...conditions];
}
function convertComparisonOp(
property: string,
value: any,
op: string,
expectedTypes?: ExpectedTypes | null
): ExpressionFilterSpecification {
let get;
if (property === '$type') {
return [op, ['geometry-type'], value] as ExpressionFilterSpecification;
} else if (property === '$id') {
get = ['id'];
} else {
get = ['get', property];
}
if (expectedTypes && value !== null) {
const type = typeof value as any;
expectedTypes[property] = type;
}
if (op === '==' && property !== '$id' && value === null) {
return [
'all',
['has', property], // missing property != null for legacy filters
['==', get, null]
];
} else if (op === '!=' && property !== '$id' && value === null) {
return [
'any',
['!', ['has', property]], // missing property != null for legacy filters
['!=', get, null]
];
}
return [op, get, value] as ExpressionFilterSpecification;
}
function convertInOp(
property: string,
values: Array<any>,
negate = false
): ExpressionFilterSpecification {
if (values.length === 0) return negate;
let get: ExpressionSpecification;
if (property === '$type') {
get = ['geometry-type'];
} else if (property === '$id') {
get = ['id'];
} else {
get = ['get', property];
}
// Determine if the list of values to be searched is homogenously typed.
// If so (and if the type is string or number), then we can use a
// [match, input, [...values], true, false] construction rather than a
// bunch of `==` tests.
let uniformTypes = true;
const type = typeof values[0];
for (const value of values) {
if (typeof value !== type) {
uniformTypes = false;
break;
}
}
if (uniformTypes && (type === 'string' || type === 'number')) {
// Match expressions must have unique values.
const uniqueValues = values.sort().filter((v, i) => i === 0 || values[i - 1] !== v);
return ['match', get, uniqueValues, !negate, negate];
}
if (negate) {
return ['all', ...values.map((v) => ['!=', get, v] as ExpressionSpecification)];
} else {
return ['any', ...values.map((v) => ['==', get, v] as ExpressionSpecification)];
}
}
function convertHasOp(property: string): ExpressionFilterSpecification {
if (property === '$type') {
return true;
} else if (property === '$id') {
return ['!=', ['id'], null];
} else {
return ['has', property];
}
}

View File

@@ -0,0 +1,661 @@
import {featureFilter, isExpressionFilter} from '.';
import {convertFilter} from './convert';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {FilterSpecification} from '../types.g';
import {Feature} from '../expression';
import {getGeometry} from '../../test/lib/geometry';
import {describe, test, expect, vi, beforeEach} from 'vitest';
describe('filter', () => {
test('expression, zoom', () => {
const f = featureFilter(['>=', ['number', ['get', 'x']], ['zoom']]).filter;
expect(f({zoom: 1}, {properties: {x: 0}} as any as Feature)).toBe(false);
expect(f({zoom: 1}, {properties: {x: 1.5}} as any as Feature)).toBe(true);
expect(f({zoom: 1}, {properties: {x: 2.5}} as any as Feature)).toBe(true);
expect(f({zoom: 2}, {properties: {x: 0}} as any as Feature)).toBe(false);
expect(f({zoom: 2}, {properties: {x: 1.5}} as any as Feature)).toBe(false);
expect(f({zoom: 2}, {properties: {x: 2.5}} as any as Feature)).toBe(true);
});
test('expression, compare two properties', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const f = featureFilter(['==', ['string', ['get', 'x']], ['string', ['get', 'y']]]).filter;
expect(f({zoom: 0}, {properties: {x: 1, y: 1}} as any as Feature)).toBe(false);
expect(f({zoom: 0}, {properties: {x: '1', y: '1'}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: 'same', y: 'same'}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: null}} as any as Feature)).toBe(false);
expect(f({zoom: 0}, {properties: {x: undefined}} as any as Feature)).toBe(false);
});
test('expression, collator comparison', () => {
const caseSensitive = featureFilter([
'==',
['string', ['get', 'x']],
['string', ['get', 'y']],
['collator', {'case-sensitive': true}]
]).filter;
expect(caseSensitive({zoom: 0}, {properties: {x: 'a', y: 'b'}} as any as Feature)).toBe(
false
);
expect(caseSensitive({zoom: 0}, {properties: {x: 'a', y: 'A'}} as any as Feature)).toBe(
false
);
expect(caseSensitive({zoom: 0}, {properties: {x: 'a', y: 'a'}} as any as Feature)).toBe(
true
);
const caseInsensitive = featureFilter([
'==',
['string', ['get', 'x']],
['string', ['get', 'y']],
['collator', {'case-sensitive': false}]
]).filter;
expect(caseInsensitive({zoom: 0}, {properties: {x: 'a', y: 'b'}} as any as Feature)).toBe(
false
);
expect(caseInsensitive({zoom: 0}, {properties: {x: 'a', y: 'A'}} as any as Feature)).toBe(
true
);
expect(caseInsensitive({zoom: 0}, {properties: {x: 'a', y: 'a'}} as any as Feature)).toBe(
true
);
});
test('expression, any/all', () => {
expect(featureFilter(['all']).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['all', true]).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['all', true, false]).filter(undefined, undefined)).toBe(false);
expect(featureFilter(['all', true, true]).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['any']).filter(undefined, undefined)).toBe(false);
expect(featureFilter(['any', true]).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['any', true, false]).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['any', false, false]).filter(undefined, undefined)).toBe(false);
});
test('expression, literal', () => {
expect(featureFilter(['literal', true]).filter(undefined, undefined)).toBe(true);
expect(featureFilter(['literal', false]).filter(undefined, undefined)).toBe(false);
});
test('expression, match', () => {
const match = featureFilter(['match', ['get', 'x'], ['a', 'b', 'c'], true, false]).filter;
expect(match(undefined, {properties: {x: 'a'}} as any as Feature)).toBe(true);
expect(match(undefined, {properties: {x: 'c'}} as any as Feature)).toBe(true);
expect(match(undefined, {properties: {x: 'd'}} as any as Feature)).toBe(false);
});
test('expression, type error', () => {
expect(() => {
featureFilter(['==', ['number', ['get', 'x']], ['string', ['get', 'y']]]);
}).toThrowError(": Cannot compare types 'number' and 'string'.");
expect(() => {
featureFilter(['number', ['get', 'x']]);
}).toThrowError(': Expected boolean but found number instead.');
expect(() => {
featureFilter(['boolean', ['get', 'x']]);
}).not.toThrowError();
});
test('expression, within', () => {
const withinFilter = featureFilter([
'within',
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[5, 0],
[5, 5],
[0, 5],
[0, 0]
]
]
}
]);
expect(withinFilter.needGeometry).toBe(true);
const canonical = {z: 3, x: 3, y: 3} as ICanonicalTileID;
const featureInTile = {} as Feature;
getGeometry(featureInTile, {type: 'Point', coordinates: [2, 2]}, canonical);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(true);
getGeometry(featureInTile, {type: 'Point', coordinates: [6, 6]}, canonical);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false);
getGeometry(featureInTile, {type: 'Point', coordinates: [5, 5]}, canonical);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false);
getGeometry(
featureInTile,
{
type: 'LineString',
coordinates: [
[2, 2],
[3, 3]
]
},
canonical
);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(true);
getGeometry(
featureInTile,
{
type: 'LineString',
coordinates: [
[6, 6],
[2, 2]
]
},
canonical
);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false);
getGeometry(
featureInTile,
{
type: 'LineString',
coordinates: [
[5, 5],
[2, 2]
]
},
canonical
);
expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false);
});
test('expression, global-state', () => {
const {filter} = featureFilter(['==', ['global-state', 'x'], ['get', 'x']], {x: 1});
expect(filter(undefined, {properties: {x: 1}} as any as Feature)).toBe(true);
expect(filter(undefined, {properties: {x: 2}} as any as Feature)).toBe(false);
});
legacyFilterTests(featureFilter);
});
describe('getGlobalStateRefs', () => {
test('returns global-state keys', () => {
const filter = featureFilter(['==', ['global-state', 'x'], ['zoom']]);
expect(filter.getGlobalStateRefs()).toEqual(new Set(['x']));
});
});
describe('legacy filter detection', () => {
test('definitely legacy filters', () => {
// Expressions with more than two arguments.
expect(isExpressionFilter(['in', 'color', 'red', 'blue'])).toBeFalsy();
// Expressions where the second argument is not a string or array.
expect(isExpressionFilter(['in', 'value', 42])).toBeFalsy();
expect(isExpressionFilter(['in', 'value', true])).toBeFalsy();
});
test('ambiguous value', () => {
// Should err on the side of reporting as a legacy filter. Style authors can force filters
// by using a literal expression as the first argument.
expect(isExpressionFilter(['in', 'color', 'red'])).toBeFalsy();
});
test('definitely expressions', () => {
expect(isExpressionFilter(['in', ['get', 'color'], 'reddish'])).toBeTruthy();
expect(isExpressionFilter(['in', ['get', 'color'], ['red', 'blue']])).toBeTruthy();
expect(isExpressionFilter(['in', 42, 42])).toBeTruthy();
expect(isExpressionFilter(['in', true, true])).toBeTruthy();
expect(isExpressionFilter(['in', 'red', ['get', 'colors']])).toBeTruthy();
});
});
describe('convert legacy filters to expressions', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
legacyFilterTests((f) => {
const converted = convertFilter(f);
return featureFilter(converted);
});
test('mimic legacy type mismatch semantics', () => {
const filter = [
'any',
['all', ['>', 'y', 0], ['>', 'y', 0]],
['>', 'x', 0]
] as FilterSpecification;
const converted = convertFilter(filter);
const f = featureFilter(converted).filter;
expect(f({zoom: 0}, {properties: {x: 0, y: 1, z: 1}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: 1, y: 0, z: 1}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: 0, y: 0, z: 1}} as any as Feature)).toBe(false);
expect(f({zoom: 0}, {properties: {x: null, y: 1, z: 1}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: 1, y: null, z: 1}} as any as Feature)).toBe(true);
expect(f({zoom: 0}, {properties: {x: null, y: null, z: 1}} as any as Feature)).toBe(false);
});
test('flattens nested, single child all expressions', () => {
const filter: FilterSpecification = [
'all',
['in', '$type', 'Polygon', 'LineString', 'Point'],
['all', ['in', 'type', 'island']]
];
const expected: FilterSpecification = [
'all',
['match', ['geometry-type'], ['LineString', 'Point', 'Polygon'], true, false],
['match', ['get', 'type'], ['island'], true, false]
];
const converted = convertFilter(filter);
expect(converted).toEqual(expected);
});
test('removes duplicates when outputting match expressions', () => {
const filter = ['in', '$id', 1, 2, 3, 2, 1] as FilterSpecification;
const expected = ['match', ['id'], [1, 2, 3], true, false];
const converted = convertFilter(filter);
expect(converted).toEqual(expected);
});
});
function legacyFilterTests(createFilterExpr) {
test('degenerate', () => {
expect(createFilterExpr().filter()).toBe(true);
expect(createFilterExpr(undefined).filter()).toBe(true);
expect(createFilterExpr(null).filter()).toBe(true);
});
test('==, string', () => {
const f = createFilterExpr(['==', 'foo', 'bar']).filter;
expect(f({zoom: 0}, {properties: {foo: 'bar'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 'baz'}})).toBe(false);
});
test('==, number', () => {
const f = createFilterExpr(['==', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('==, null', () => {
const f = createFilterExpr(['==', 'foo', null]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
// t.equal(f({zoom: 0}, {properties: {foo: undefined}}), false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('==, $type', () => {
const f = createFilterExpr(['==', '$type', 'LineString']).filter;
expect(f({zoom: 0}, {type: 1})).toBe(false);
expect(f({zoom: 0}, {type: 2})).toBe(true);
});
test('==, $id', () => {
const f = createFilterExpr(['==', '$id', 1234]).filter;
expect(f({zoom: 0}, {id: 1234})).toBe(true);
expect(f({zoom: 0}, {id: '1234'})).toBe(false);
expect(f({zoom: 0}, {properties: {id: 1234}})).toBe(false);
});
test('!=, string', () => {
const f = createFilterExpr(['!=', 'foo', 'bar']).filter;
expect(f({zoom: 0}, {properties: {foo: 'bar'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 'baz'}})).toBe(true);
});
test('!=, number', () => {
const f = createFilterExpr(['!=', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(true);
expect(f({zoom: 0}, {properties: {}})).toBe(true);
});
test('!=, null', () => {
const f = createFilterExpr(['!=', 'foo', null]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
// t.equal(f({zoom: 0}, {properties: {foo: undefined}}), true);
expect(f({zoom: 0}, {properties: {}})).toBe(true);
});
test('!=, $type', () => {
const f = createFilterExpr(['!=', '$type', 'LineString']).filter;
expect(f({zoom: 0}, {type: 1})).toBe(true);
expect(f({zoom: 0}, {type: 2})).toBe(false);
});
test('<, number', () => {
const f = createFilterExpr(['<', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('<, string', () => {
const f = createFilterExpr(['<', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
});
test('<=, number', () => {
const f = createFilterExpr(['<=', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('<=, string', () => {
const f = createFilterExpr(['<=', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
});
test('>, number', () => {
const f = createFilterExpr(['>', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('>, string', () => {
const f = createFilterExpr(['>', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
});
test('>=, number', () => {
const f = createFilterExpr(['>=', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('>=, string', () => {
const f = createFilterExpr(['>=', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: -1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '1'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '-1'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
});
test('in, degenerate', () => {
const f = createFilterExpr(['in', 'foo']).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
});
test('in, string', () => {
const f = createFilterExpr(['in', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('in, number', () => {
const f = createFilterExpr(['in', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
});
test('in, null', () => {
const f = createFilterExpr(['in', 'foo', null]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
// t.equal(f({zoom: 0}, {properties: {foo: undefined}}), false);
});
test('in, multiple', () => {
const f = createFilterExpr(['in', 'foo', 0, 1]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 3}})).toBe(false);
});
test('in, large_multiple', () => {
const values = Array.from({length: 2000}).map(Number.call, Number);
values.reverse();
const f = createFilterExpr(['in', 'foo'].concat(values)).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1999}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 2000}})).toBe(false);
});
test('in, large_multiple, heterogeneous', () => {
const values = Array.from({length: 2000}).map(Number.call, Number);
values.push('a');
values.unshift('b');
const f = createFilterExpr(['in', 'foo'].concat(values)).filter;
expect(f({zoom: 0}, {properties: {foo: 'b'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 'a'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1999}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 2000}})).toBe(false);
});
test('in, $type', () => {
const f = createFilterExpr(['in', '$type', 'LineString', 'Polygon']).filter;
expect(f({zoom: 0}, {type: 1})).toBe(false);
expect(f({zoom: 0}, {type: 2})).toBe(true);
expect(f({zoom: 0}, {type: 3})).toBe(true);
const f1 = createFilterExpr(['in', '$type', 'Polygon', 'LineString', 'Point']).filter;
expect(f1({zoom: 0}, {type: 1})).toBe(true);
expect(f1({zoom: 0}, {type: 2})).toBe(true);
expect(f1({zoom: 0}, {type: 3})).toBe(true);
});
test('!in, degenerate', () => {
const f = createFilterExpr(['!in', 'foo']).filter;
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
});
test('!in, string', () => {
const f = createFilterExpr(['!in', 'foo', '0']).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(true);
expect(f({zoom: 0}, {properties: {}})).toBe(true);
});
test('!in, number', () => {
const f = createFilterExpr(['!in', 'foo', 0]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(true);
});
test('!in, null', () => {
const f = createFilterExpr(['!in', 'foo', null]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
// t.equal(f({zoom: 0}, {properties: {foo: undefined}}), true);
});
test('!in, multiple', () => {
const f = createFilterExpr(['!in', 'foo', 0, 1]).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 3}})).toBe(true);
});
test('!in, large_multiple', () => {
const f = createFilterExpr(
['!in', 'foo'].concat(Array.from({length: 2000}).map(Number.call, Number))
).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1999}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 2000}})).toBe(true);
});
test('!in, $type', () => {
const f = createFilterExpr(['!in', '$type', 'LineString', 'Polygon']).filter;
expect(f({zoom: 0}, {type: 1})).toBe(true);
expect(f({zoom: 0}, {type: 2})).toBe(false);
expect(f({zoom: 0}, {type: 3})).toBe(false);
});
test('any', () => {
const f1 = createFilterExpr(['any']).filter;
expect(f1({zoom: 0}, {properties: {foo: 1}})).toBe(false);
const f2 = createFilterExpr(['any', ['==', 'foo', 1]]).filter;
expect(f2({zoom: 0}, {properties: {foo: 1}})).toBe(true);
const f3 = createFilterExpr(['any', ['==', 'foo', 0]]).filter;
expect(f3({zoom: 0}, {properties: {foo: 1}})).toBe(false);
const f4 = createFilterExpr(['any', ['==', 'foo', 0], ['==', 'foo', 1]]).filter;
expect(f4({zoom: 0}, {properties: {foo: 1}})).toBe(true);
});
test('all', () => {
const f1 = createFilterExpr(['all']).filter;
expect(f1({zoom: 0}, {properties: {foo: 1}})).toBe(true);
const f2 = createFilterExpr(['all', ['==', 'foo', 1]]).filter;
expect(f2({zoom: 0}, {properties: {foo: 1}})).toBe(true);
const f3 = createFilterExpr(['all', ['==', 'foo', 0]]).filter;
expect(f3({zoom: 0}, {properties: {foo: 1}})).toBe(false);
const f4 = createFilterExpr(['all', ['==', 'foo', 0], ['==', 'foo', 1]]).filter;
expect(f4({zoom: 0}, {properties: {foo: 1}})).toBe(false);
});
test('none', () => {
const f1 = createFilterExpr(['none']).filter;
expect(f1({zoom: 0}, {properties: {foo: 1}})).toBe(true);
const f2 = createFilterExpr(['none', ['==', 'foo', 1]]).filter;
expect(f2({zoom: 0}, {properties: {foo: 1}})).toBe(false);
const f3 = createFilterExpr(['none', ['==', 'foo', 0]]).filter;
expect(f3({zoom: 0}, {properties: {foo: 1}})).toBe(true);
const f4 = createFilterExpr(['none', ['==', 'foo', 0], ['==', 'foo', 1]]).filter;
expect(f4({zoom: 0}, {properties: {foo: 1}})).toBe(false);
});
test('has', () => {
const f = createFilterExpr(['has', 'foo']).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: true}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(true);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(true);
expect(f({zoom: 0}, {properties: {}})).toBe(false);
});
test('!has', () => {
const f = createFilterExpr(['!has', 'foo']).filter;
expect(f({zoom: 0}, {properties: {foo: 0}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: 1}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: '0'}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: false}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: null}})).toBe(false);
expect(f({zoom: 0}, {properties: {foo: undefined}})).toBe(false);
expect(f({zoom: 0}, {properties: {}})).toBe(true);
});
}

View File

@@ -0,0 +1,208 @@
import {createExpression, findGlobalStateRefs} from '../expression';
import type {GlobalProperties, Feature} from '../expression';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {StylePropertySpecification} from '..';
import {ExpressionFilterSpecification, type FilterSpecification} from '../types.g';
type FilterExpression = (
globalProperties: GlobalProperties,
feature: Feature,
canonical?: ICanonicalTileID
) => boolean;
export type FeatureFilter = {
filter: FilterExpression;
needGeometry: boolean;
getGlobalStateRefs: () => Set<string>;
};
export function isExpressionFilter(filter: any): filter is ExpressionFilterSpecification {
if (filter === true || filter === false) {
return true;
}
if (!Array.isArray(filter) || filter.length === 0) {
return false;
}
switch (filter[0]) {
case 'has':
return filter.length >= 2 && filter[1] !== '$id' && filter[1] !== '$type';
case 'in':
return (
filter.length >= 3 && (typeof filter[1] !== 'string' || Array.isArray(filter[2]))
);
case '!in':
case '!has':
case 'none':
return false;
case '==':
case '!=':
case '>':
case '>=':
case '<':
case '<=':
return filter.length !== 3 || Array.isArray(filter[1]) || Array.isArray(filter[2]);
case 'any':
case 'all':
for (const f of filter.slice(1)) {
if (!isExpressionFilter(f) && typeof f !== 'boolean') {
return false;
}
}
return true;
default:
return true;
}
}
const filterSpec = {
type: 'boolean',
default: false,
transition: false,
'property-type': 'data-driven',
expression: {
interpolated: false,
parameters: ['zoom', 'feature']
}
};
/**
* Given a filter expressed as nested arrays, return a new function
* that evaluates whether a given feature (with a .properties or .tags property)
* passes its test.
*
* @private
* @param filter MapLibre filter
* @param [globalState] Global state object to be used for evaluating 'global-state' expressions
* @returns filter-evaluating function
*/
export function featureFilter(
filter: FilterSpecification | void,
globalState?: Record<string, any>
): FeatureFilter {
if (filter === null || filter === undefined) {
return {filter: () => true, needGeometry: false, getGlobalStateRefs: () => new Set()};
}
if (!isExpressionFilter(filter)) {
filter = convertFilter(filter) as ExpressionFilterSpecification;
}
const compiled = createExpression(
filter,
filterSpec as StylePropertySpecification,
globalState
);
if (compiled.result === 'error') {
throw new Error(compiled.value.map((err) => `${err.key}: ${err.message}`).join(', '));
} else {
const needGeometry = geometryNeeded(filter);
return {
filter: (
globalProperties: GlobalProperties,
feature: Feature,
canonical?: ICanonicalTileID
) => compiled.value.evaluate(globalProperties, feature, {}, canonical),
needGeometry,
getGlobalStateRefs: () => findGlobalStateRefs(compiled.value.expression)
};
}
}
// Comparison function to sort numbers and strings
function compare(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
function geometryNeeded(filter) {
if (!Array.isArray(filter)) return false;
if (filter[0] === 'within' || filter[0] === 'distance') return true;
for (let index = 1; index < filter.length; index++) {
if (geometryNeeded(filter[index])) return true;
}
return false;
}
function convertFilter(filter?: Array<any> | null | void): unknown {
if (!filter) return true;
const op = filter[0];
if (filter.length <= 1) return op !== 'any';
const converted =
op === '=='
? convertComparisonOp(filter[1], filter[2], '==')
: op === '!='
? convertNegation(convertComparisonOp(filter[1], filter[2], '=='))
: op === '<' || op === '>' || op === '<=' || op === '>='
? convertComparisonOp(filter[1], filter[2], op)
: op === 'any'
? convertDisjunctionOp(filter.slice(1))
: op === 'all'
? ['all' as unknown].concat(filter.slice(1).map(convertFilter))
: op === 'none'
? ['all' as unknown].concat(
filter.slice(1).map(convertFilter).map(convertNegation)
)
: op === 'in'
? convertInOp(filter[1], filter.slice(2))
: op === '!in'
? convertNegation(convertInOp(filter[1], filter.slice(2)))
: op === 'has'
? convertHasOp(filter[1])
: op === '!has'
? convertNegation(convertHasOp(filter[1]))
: true;
return converted;
}
function convertComparisonOp(property: string, value: any, op: string) {
switch (property) {
case '$type':
return [`filter-type-${op}`, value];
case '$id':
return [`filter-id-${op}`, value];
default:
return [`filter-${op}`, property, value];
}
}
function convertDisjunctionOp(filters: Array<Array<any>>) {
return ['any' as unknown].concat(filters.map(convertFilter));
}
function convertInOp(property: string, values: Array<any>) {
if (values.length === 0) {
return false;
}
switch (property) {
case '$type':
return ['filter-type-in', ['literal', values]];
case '$id':
return ['filter-id-in', ['literal', values]];
default:
if (values.length > 200 && !values.some((v) => typeof v !== typeof values[0])) {
return ['filter-in-large', property, ['literal', values.sort(compare)]];
} else {
return ['filter-in-small', property, ['literal', values]];
}
}
}
function convertHasOp(property: string) {
switch (property) {
case '$type':
return true;
case '$id':
return ['filter-has-id'];
default:
return ['filter-has', property];
}
}
function convertNegation(filter: unknown) {
return ['!', filter];
}

View File

@@ -0,0 +1,39 @@
import {format} from './format';
import {describe, test, expect} from 'vitest';
function roundtrip(style) {
return JSON.parse(format(style));
}
describe('format', () => {
test('orders top-level keys', () => {
expect(
Object.keys(
roundtrip({
layers: [],
other: {},
sources: {},
glyphs: '',
sprite: '',
version: 6
})
)
).toEqual(['version', 'sources', 'sprite', 'glyphs', 'layers', 'other']);
});
test('orders layer keys', () => {
expect(
Object.keys(
roundtrip({
layers: [
{
paint: {},
layout: {},
id: 'id',
type: 'type'
}
]
}).layers[0]
)
).toEqual(['id', 'type', 'layout', 'paint']);
});
});

View File

@@ -0,0 +1,48 @@
import {latest} from './reference/latest';
import stringifyPretty from 'json-stringify-pretty-compact';
function sortKeysBy(obj, reference) {
const result = {};
for (const key in reference) {
if (obj[key] !== undefined) {
result[key] = obj[key];
}
}
for (const key in obj) {
if (result[key] === undefined) {
result[key] = obj[key];
}
}
return result;
}
/**
* Format a MapLibre Style. Returns a stringified style with its keys
* sorted in the same order as the reference style.
*
* The optional `space` argument is passed to
* [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
* to generate formatted output.
*
* If `space` is unspecified, a default of `2` spaces will be used.
*
* @private
* @param {Object} style a MapLibre Style
* @param {number} [space] space argument to pass to `JSON.stringify`
* @returns {string} stringified formatted JSON
* @example
* var fs = require('fs');
* var format = require('maplibre-gl-style-spec').format;
* var style = fs.readFileSync('./source.json', 'utf8');
* fs.writeFileSync('./dest.json', format(style));
* fs.writeFileSync('./dest.min.json', format(style, 0));
*/
export function format(style, space = 2) {
style = sortKeysBy(style, latest.$root);
if (style.layers) {
style.layers = style.layers.map((layer) => sortKeysBy(layer, latest.layer));
}
return stringifyPretty(style, {indent: space});
}

View File

@@ -0,0 +1,278 @@
import type {StylePropertySpecification} from '..';
function convertLiteral(value) {
return typeof value === 'object' ? ['literal', value] : value;
}
export function convertFunction(parameters: any, propertySpec: StylePropertySpecification) {
let stops = parameters.stops;
if (!stops) {
// identity function
return convertIdentityFunction(parameters, propertySpec);
}
const zoomAndFeatureDependent = stops && typeof stops[0][0] === 'object';
const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined;
const zoomDependent = zoomAndFeatureDependent || !featureDependent;
stops = stops.map((stop) => {
if (!featureDependent && (propertySpec as any).tokens && typeof stop[1] === 'string') {
return [stop[0], convertTokenString(stop[1])];
}
return [stop[0], convertLiteral(stop[1])];
});
if (zoomAndFeatureDependent) {
return convertZoomAndPropertyFunction(parameters, propertySpec, stops);
} else if (zoomDependent) {
return convertZoomFunction(parameters, propertySpec, stops);
} else {
return convertPropertyFunction(parameters, propertySpec, stops);
}
}
function convertIdentityFunction(parameters, propertySpec): Array<unknown> {
const get = ['get', parameters.property];
if (parameters.default === undefined) {
// By default, expressions for string-valued properties get coerced. To preserve
// legacy function semantics, insert an explicit assertion instead.
return propertySpec.type === 'string' ? ['string', get] : get;
} else if (propertySpec.type === 'enum') {
return ['match', get, Object.keys(propertySpec.values), get, parameters.default];
} else {
const expression = [
propertySpec.type === 'color' ? 'to-color' : propertySpec.type,
get,
convertLiteral(parameters.default)
];
if (propertySpec.type === 'array') {
expression.splice(1, 0, propertySpec.value, propertySpec.length || null);
}
return expression;
}
}
function getInterpolateOperator(parameters) {
switch (parameters.colorSpace) {
case 'hcl':
return 'interpolate-hcl';
case 'lab':
return 'interpolate-lab';
default:
return 'interpolate';
}
}
function convertZoomAndPropertyFunction(parameters, propertySpec, stops) {
const featureFunctionParameters = {};
const featureFunctionStops = {};
const zoomStops = [];
for (let s = 0; s < stops.length; s++) {
const stop = stops[s];
const zoom = stop[0].zoom;
if (featureFunctionParameters[zoom] === undefined) {
featureFunctionParameters[zoom] = {
zoom,
type: parameters.type,
property: parameters.property,
default: parameters.default
};
featureFunctionStops[zoom] = [];
zoomStops.push(zoom);
}
featureFunctionStops[zoom].push([stop[0].value, stop[1]]);
}
// the interpolation type for the zoom dimension of a zoom-and-property
// function is determined directly from the style property specification
// for which it's being used: linear for interpolatable properties, step
// otherwise.
const functionType = getFunctionType({}, propertySpec);
if (functionType === 'exponential') {
const expression = [getInterpolateOperator(parameters), ['linear'], ['zoom']];
for (const z of zoomStops) {
const output = convertPropertyFunction(
featureFunctionParameters[z],
propertySpec,
featureFunctionStops[z]
);
appendStopPair(expression, z, output, false);
}
return expression;
} else {
const expression = ['step', ['zoom']];
for (const z of zoomStops) {
const output = convertPropertyFunction(
featureFunctionParameters[z],
propertySpec,
featureFunctionStops[z]
);
appendStopPair(expression, z, output, true);
}
fixupDegenerateStepCurve(expression);
return expression;
}
}
function coalesce(a, b) {
if (a !== undefined) return a;
if (b !== undefined) return b;
}
function getFallback(parameters, propertySpec) {
const defaultValue = convertLiteral(coalesce(parameters.default, propertySpec.default));
/*
* Some fields with type: resolvedImage have an undefined default.
* Because undefined is an invalid value for resolvedImage, set fallback to
* an empty string instead of undefined to ensure output
* passes validation.
*/
if (defaultValue === undefined && propertySpec.type === 'resolvedImage') {
return '';
}
return defaultValue;
}
function convertPropertyFunction(parameters, propertySpec, stops) {
const type = getFunctionType(parameters, propertySpec);
const get = ['get', parameters.property];
if (type === 'categorical' && typeof stops[0][0] === 'boolean') {
const expression: any = ['case'];
for (const stop of stops) {
expression.push(['==', get, stop[0]], stop[1]);
}
expression.push(getFallback(parameters, propertySpec));
return expression;
} else if (type === 'categorical') {
const expression = ['match', get];
for (const stop of stops) {
appendStopPair(expression, stop[0], stop[1], false);
}
expression.push(getFallback(parameters, propertySpec));
return expression;
} else if (type === 'interval') {
const expression = ['step', ['number', get]];
for (const stop of stops) {
appendStopPair(expression, stop[0], stop[1], true);
}
fixupDegenerateStepCurve(expression);
return parameters.default === undefined
? expression
: [
'case',
['==', ['typeof', get], 'number'],
expression,
convertLiteral(parameters.default)
];
} else if (type === 'exponential') {
const base = parameters.base !== undefined ? parameters.base : 1;
const expression = [
getInterpolateOperator(parameters),
base === 1 ? ['linear'] : ['exponential', base],
['number', get]
];
for (const stop of stops) {
appendStopPair(expression, stop[0], stop[1], false);
}
return parameters.default === undefined
? expression
: [
'case',
['==', ['typeof', get], 'number'],
expression,
convertLiteral(parameters.default)
];
} else {
throw new Error(`Unknown property function type ${type}`);
}
}
function convertZoomFunction(parameters, propertySpec, stops, input = ['zoom']) {
const type = getFunctionType(parameters, propertySpec);
let expression;
let isStep = false;
if (type === 'interval') {
expression = ['step', input];
isStep = true;
} else if (type === 'exponential') {
const base = parameters.base !== undefined ? parameters.base : 1;
expression = [
getInterpolateOperator(parameters),
base === 1 ? ['linear'] : ['exponential', base],
input
];
} else {
throw new Error(`Unknown zoom function type "${type}"`);
}
for (const stop of stops) {
appendStopPair(expression, stop[0], stop[1], isStep);
}
fixupDegenerateStepCurve(expression);
return expression;
}
function fixupDegenerateStepCurve(expression) {
// degenerate step curve (i.e. a constant function): add a noop stop
if (expression[0] === 'step' && expression.length === 3) {
expression.push(0);
expression.push(expression[3]);
}
}
function appendStopPair(curve, input, output, isStep) {
// Skip duplicate stop values. They were not validated for functions, but they are for expressions.
// https://github.com/mapbox/mapbox-gl-js/issues/4107
if (curve.length > 3 && input === curve[curve.length - 2]) {
return;
}
// step curves don't get the first input value, as it is redundant.
if (!(isStep && curve.length === 2)) {
curve.push(input);
}
curve.push(output);
}
function getFunctionType(parameters, propertySpec) {
if (parameters.type) {
return parameters.type;
} else {
return (propertySpec.expression as any).interpolated ? 'exponential' : 'interval';
}
}
// "String with {name} token" => ["concat", "String with ", ["get", "name"], " token"]
export function convertTokenString(s: string) {
const result: any = ['concat'];
const re = /{([^{}]+)}/g;
let pos = 0;
for (let match = re.exec(s); match !== null; match = re.exec(s)) {
const literal = s.slice(pos, re.lastIndex - match[0].length);
pos = re.lastIndex;
if (literal.length > 0) result.push(literal);
result.push(['get', match[1]]);
}
if (result.length === 1) {
return s;
}
if (pos < s.length) {
result.push(s.slice(pos));
} else if (result.length === 2) {
return ['to-string', result[1]];
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More