itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
486 lines (479 loc) • 20.8 kB
JavaScript
import { FEATURE_TYPES } from "./Feature.js";
import * as maplibre from '@maplibre/maplibre-gl-style-spec';
/**
* An object that can contain any properties (zoom, fill, stroke, point,
* text or/and icon) and sub properties of a Style.
* Used for the instanciation of a {@link Style}.
*
* @typedef {Object} StyleOptions
*
* @property {Object} [zoom] - Level on which to display the feature
* @property {Number} [zoom.max] - max level
* @property {Number} [zoom.min] - min level
*
* @property {Object} [fill] - Fill style for polygons.
* @property {String|Function|THREE.Color} [fill.color] - Defines the main fill color. Can be
* any [valid color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Default is no value, which means no fill.
* If the `Layer` is a `GeometryLayer` you can use `THREE.Color`.
* @property {Image|Canvas|String|Object|Function} [fill.pattern] - Defines a pattern to fill the
* surface with. It can be an `Image` to use directly, an url to fetch the pattern or an object containing
* the url of the image to fetch and the transformation to apply.
* from. See [this example](http://www.itowns-project.org/itowns/examples/#source_file_geojson_raster)
* for how to use.
* @property {Image|String} [fill.pattern.source] - The image or the url to fetch the pattern image
* @property {Object} [fill.pattern.cropValues] - The x, y, width and height (in pixel) of the sub image to use.
* @property {THREE.Color} [fill.pattern.color] - Can be any
* [valid color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* It will change the color of the white pixels of the source image.
* @property {Number|Function} [fill.opacity] - The opacity of the color or of the
* pattern. Can be between `0.0` and `1.0`. Default is `1.0`.
* For a `GeometryLayer`, this opacity property isn't used.
* @property {Number|Function} [fill.base_altitude] - `GeometryLayer` style option, defines altitude
* for each coordinate.
* If `base_altitude` is `undefined`, the original altitude is kept, and if it doesn't exist
* then the altitude value is set to 0.
* @property {Number|Function} [fill.extrusion_height] - `GeometryLayer` style option, if defined,
* polygons will be extruded by the specified amount
*
* @property {Object} [stroke] - Lines and polygons edges.
* @property {String|Function|THREE.Color} [stroke.color] The color of the line. Can be any [valid
* color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Default is no value, which means no stroke.
* If the `Layer` is a `GeometryLayer` you can use `THREE.Color`.
* @property {Number|Function} [stroke.opacity] - The opacity of the line. Can be between
* `0.0` and `1.0`. Default is `1.0`.
* For a `GeometryLayer`, this opacity property isn't used.
* @property {Number|Function} [stroke.width] - The width of the line. Default is `1.0`.
* @property {Number|Function} [stroke.base_altitude] - `GeometryLayer` style option, defines altitude
* for each coordinate.
* If `base_altitude` is `undefined`, the original altitude is kept, and if it doesn't exist
* then the altitude value is set to 0.
*
* @property {Object} [point] - Point style.
* @property {String|Function} [point.color] - The color of the point. Can be any [valid
* color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Default is no value, which means points won't be displayed.
* @property {Number|Function} [point.radius] - The radius of the point, in pixel. Default
* is `2.0`.
* @property {String|Function} [point.line] - The color of the border of the point. Can be
* any [valid color
* string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Not supported for a `GeometryLayer`.
* @property {Number|Function} [point.width] - The width of the border, in pixel. Default
* is `0.0` (no border).
* @property {Number|Function} [point.opacity] - The opacity of the point. Can be between
* `0.0` and `1.0`. Default is `1.0`.
* Not supported for `GeometryLayer`.
* @property {Number|Function} [point.base_altitude] - `GeometryLayer` style option, defines altitude
* for each coordinate.
* If `base_altitude` is `undefined`, the original altitude is kept, and if it doesn't exist
* then the altitude value is set to 0.
* @property {Object} [point.model] - 3D model to instantiate at each point position.
*
* @property {Object} [text] - All things {@link Label} related. (Supported for Points features, not yet
* for Lines and Polygons features.)
* @property {String|Function} [text.field] - A string representing a property key of
* a `FeatureGeometry` enclosed in brackets, that will be replaced by the value of the
* property for each geometry. For example, if each geometry contains a `name` property,
* `text.field` can be set to `{name}`. Default is no value, indicating that no
* text will be displayed.
*
* It's also possible to create more complex expressions. For example, you can combine
* text that will always be displayed (e.g. `foo`) and variable properties (e.g. `{bar}`)
* like the following: `foo {bar}`. You can also use multiple variables in one field.
* Let's say for instance that you have two properties latin name and local name of a
* place, you can write something like `{name_latin} - {name_local}` which can result
* in `Marrakesh - مراكش` for example.
* @property {String|Function} [text.color] - The color of the text. Can be any [valid
* color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Default is `#000000`.
* @property {String|Number[]|Function} [text.anchor] - The anchor of the text compared to its
* position (see {@link Label} for the position). Can be one of the following values: `top`,
* `left`, `bottom`, `right`, `center`, `top-left`, `top-right`, `bottom-left`
* or `bottom-right`. Default is `center`.
*
* It can also be defined as an Array of two numbers. Each number defines an offset (in
* fraction of the label width and height) between the label position and the top-left
* corner of the text. The first value is the horizontal offset, and the second is the
* vertical offset. For example, `[-0.5, -0.5]` will be equivalent to `center`.
* @property {Array|Function} [text.offset] - The offset of the text, depending on its
* anchor, in pixels. First value is from `left`, second is from `top`. Default
* is `[0, 0]`.
* @property {Number|Function} [text.padding] - The padding outside the text, in pixels.
* Default is `2`.
* @property {Number|Function} [text.size] - The size of the font, in pixels. Default is
* `16`.
* @property {Number|Function} [text.wrap] - The maximum width, in pixels, before the text
* is wrapped, because the string is too long. Default is `10`.
* @property {Number|Function} [text.spacing] - The spacing between the letters, in `em`.
* Default is `0`.
* @property {String|Function} [text.transform] - A value corresponding to the [CSS
* property
* `text-transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform).
* Default is `none`.
* @property {String|Function} [text.justify] - A value corresponding to the [CSS property
* `text-align`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-align).
* Default is `center`.
* @property {Number|Function} [text.opacity] - The opacity of the text. Can be between
* `0.0` and `1.0`. Default is `1.0`.
* @property {Array|Function} [text.font] - A list (as an array of string) of font family
* names, prioritized in the order it is set. Default is `Open Sans Regular,
* Arial Unicode MS Regular, sans-serif`.
* @property {String|Function} [text.haloColor] - The color of the halo. Can be any [valid
* color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* Default is `#000000`.
* @property {Number|Function} [text.haloWidth] - The width of the halo, in pixels.
* Default is `0`.
* @property {Number|Function} [text.haloBlur] - The blur value of the halo, in pixels.
* Default is `0`.
*
* @property {Object} [icon] - Defines the appearance of icons attached to label.
* @property {String} [icon.source] - The url of the icons' image file.
* @property {String} [icon.id] - The id of the icons' sub-image in a vector tile data set.
* @property {String} [icon.cropValues] - the x, y, width and height (in pixel) of the sub image to use.
* @property {String} [icon.anchor] - The anchor of the icon compared to the label position.
* Can be `left`, `bottom`, `right`, `center`, `top-left`, `top-right`, `bottom-left`
* or `bottom-right`. Default is `center`.
* @property {Number} [icon.size] - If the icon's image is passed with `icon.source` and/or
* `icon.id`, its size when displayed on screen is multiplied by `icon.size`. Default is `1`.
* @property {String|Function} [icon.color] - The color of the icon. Can be any [valid
* color string](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
* It will change the color of the white pixels of the icon source image.
* @property {Number|Function} [icon.opacity] - The opacity of the icon. Can be between
* `0.0` and `1.0`. Default is `1.0`.
*/
/**
* generate a StyleOptions from (geojson-like) properties.
* @param {Object} properties (geojson-like) properties.
* @param {FeatureContext} featCtx the context of the feature
*
* @returns {StyleOptions} containing all properties for itowns.Style
*/
function setFromProperties(properties, featCtx) {
const type = featCtx.type;
const style = {};
if (type === FEATURE_TYPES.POINT) {
const point = {
...(properties.fill !== undefined && {
color: properties.fill
}),
...(properties['fill-opacity'] !== undefined && {
opacity: properties['fill-opacity']
}),
...(properties.stroke !== undefined && {
line: properties.stroke
}),
...(properties.radius !== undefined && {
radius: properties.radius
})
};
if (Object.keys(point).length) {
style.point = point;
}
const text = {
...(properties['label-color'] !== undefined && {
color: properties['label-color']
}),
...(properties['label-opacity'] !== undefined && {
opacity: properties['label-opacity']
}),
...(properties['label-size'] !== undefined && {
size: properties['label-size']
})
};
if (Object.keys(point).length) {
style.text = text;
}
const icon = {
...(properties.icon !== undefined && {
source: properties.icon
}),
...(properties['icon-scale'] !== undefined && {
size: properties['icon-scale']
}),
...(properties['icon-opacity'] !== undefined && {
opacity: properties['icon-opacity']
}),
...(properties['icon-color'] !== undefined && {
color: properties['icon-color']
})
};
if (Object.keys(icon).length) {
style.icon = icon;
}
} else {
const stroke = {
...(properties.stroke !== undefined && {
color: properties.stroke
}),
...(properties['stroke-width'] !== undefined && {
width: properties['stroke-width']
}),
...(properties['stroke-opacity'] !== undefined && {
opacity: properties['stroke-opacity']
})
};
if (Object.keys(stroke).length) {
style.stroke = stroke;
}
if (type !== FEATURE_TYPES.LINE) {
const fill = {
...(properties.fill !== undefined && {
color: properties.fill
}),
...(properties['fill-opacity'] !== undefined && {
opacity: properties['fill-opacity']
})
};
if (Object.keys(fill).length) {
style.fill = fill;
}
}
}
return style;
}
function readVectorProperty(property, options) {
if (property != undefined) {
if (maplibre.expression.isExpression(property)) {
return maplibre.expression.createExpression(property, options).value;
} else {
return property;
}
}
}
const inv255 = 1 / 255;
function rgba2rgb(orig) {
if (!orig) {
return {};
} else if (orig.stops || orig.expression) {
return {
color: orig
};
} else if (typeof orig == 'string') {
const result = orig.match(/(?:((hsl|rgb)a? *\(([\d.%]+(?:deg|g?rad|turn)?)[ ,]*([\d.%]+)[ ,]*([\d.%]+)[ ,/]*([\d.%]*)\))|(#((?:[\d\w]{3}){1,2})([\d\w]{1,2})?))/i);
if (result === null) {
return {
color: orig,
opacity: 1.0
};
} else if (result[7]) {
let opacity = 1.0;
if (result[9]) {
opacity = parseInt(result[9].length == 1 ? `${result[9]}${result[9]}` : result[9], 16) * inv255;
}
return {
color: `#${result[8]}`,
opacity
};
} else if (result[1]) {
return {
color: `${result[2]}(${result[3]},${result[4]},${result[5]})`,
opacity: result[6] ? Number(result[6]) : 1.0
};
}
}
}
/**
* generate a StyleOptions from vector tile layer properties.
* @param {Object} layer vector tile layer.
* @param {Object} sprites vector tile layer.
* @param {Boolean} [symbolToCircle=false]
*
* @returns {StyleOptions} containing all properties for itowns.Style
*/
function setFromVectorTileLayer(layer, sprites) {
let symbolToCircle = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
const style = {
fill: {},
stroke: {},
point: {},
text: {},
icon: {}
};
layer.layout = layer.layout || {};
layer.paint = layer.paint || {};
if (layer.type === 'fill') {
const {
color,
opacity
} = rgba2rgb(readVectorProperty(layer.paint['fill-color'] || layer.paint['fill-pattern'], {
type: 'color'
}));
style.fill.color = color;
style.fill.opacity = readVectorProperty(layer.paint['fill-opacity']) || opacity;
if (layer.paint['fill-pattern']) {
try {
style.fill.pattern = {
id: layer.paint['fill-pattern'],
source: sprites.source,
cropValues: sprites[layer.paint['fill-pattern']]
};
} catch (err) {
err.message = `VTlayer '${layer.id}': argument sprites must not be null when using layer.paint['fill-pattern']`;
throw err;
}
}
if (layer.paint['fill-outline-color']) {
const {
color,
opacity
} = rgba2rgb(readVectorProperty(layer.paint['fill-outline-color'], {
type: 'color'
}));
style.stroke.color = color;
style.stroke.opacity = opacity;
style.stroke.width = 1.0;
} else {
style.stroke.width = 0.0;
}
} else if (layer.type === 'line') {
const prepare = readVectorProperty(layer.paint['line-color'], {
type: 'color'
});
const {
color,
opacity
} = rgba2rgb(prepare);
style.stroke.dasharray = readVectorProperty(layer.paint['line-dasharray']);
style.stroke.color = color;
style.stroke.lineCap = layer.layout['line-cap'];
style.stroke.width = readVectorProperty(layer.paint['line-width']);
style.stroke.opacity = readVectorProperty(layer.paint['line-opacity']) || opacity;
} else if (layer.type === 'circle' || symbolToCircle) {
const {
color,
opacity
} = rgba2rgb(readVectorProperty(layer.paint['circle-color'], {
type: 'color'
}));
style.point.color = color;
style.point.opacity = opacity;
style.point.radius = readVectorProperty(layer.paint['circle-radius']);
} else if (layer.type === 'symbol') {
// if symbol we shouldn't draw stroke but defaut value is 1.
style.stroke.width = 0.0;
// overlapping order
style.text.zOrder = readVectorProperty(layer.layout['symbol-z-order']);
if (style.text.zOrder == 'auto') {
style.text.zOrder = readVectorProperty(layer.layout['symbol-sort-key']) || 'Y';
} else if (style.text.zOrder == 'viewport-y') {
style.text.zOrder = 'Y';
} else if (style.text.zOrder == 'source') {
style.text.zOrder = 0;
}
// position
style.text.anchor = readVectorProperty(layer.layout['text-anchor']);
style.text.offset = readVectorProperty(layer.layout['text-offset']);
style.text.padding = readVectorProperty(layer.layout['text-padding']);
style.text.size = readVectorProperty(layer.layout['text-size']);
style.text.placement = readVectorProperty(layer.layout['symbol-placement']);
style.text.rotation = readVectorProperty(layer.layout['text-rotation-alignment']);
// content
style.text.field = readVectorProperty(layer.layout['text-field']);
style.text.wrap = readVectorProperty(layer.layout['text-max-width']); // Units ems
style.text.spacing = readVectorProperty(layer.layout['text-letter-spacing']);
style.text.transform = readVectorProperty(layer.layout['text-transform']);
style.text.justify = readVectorProperty(layer.layout['text-justify']);
// appearance
const {
color,
opacity
} = rgba2rgb(readVectorProperty(layer.paint['text-color'], {
type: 'color'
}));
style.text.color = color;
style.text.opacity = readVectorProperty(layer.paint['text-opacity']) || opacity !== undefined && opacity;
style.text.font = readVectorProperty(layer.layout['text-font']);
const haloColor = readVectorProperty(layer.paint['text-halo-color'], {
type: 'color'
});
if (haloColor) {
style.text.haloColor = haloColor.color || haloColor;
style.text.haloWidth = readVectorProperty(layer.paint['text-halo-width']);
style.text.haloBlur = readVectorProperty(layer.paint['text-halo-blur']);
}
// additional icon
const iconImg = readVectorProperty(layer.layout['icon-image']);
if (iconImg) {
const cropValueDefault = {
x: 0,
y: 0,
width: 1,
height: 1
};
try {
style.icon.id = iconImg;
if (iconImg.stops) {
const iconCropValue = {
...(iconImg.base !== undefined && {
base: iconImg.base
}),
stops: iconImg.stops.map(stop => {
let cropValues = sprites[stop[1]];
if (stop[1].includes('{')) {
cropValues = function (p) {
const id = stop[1].replace(/\{(.+?)\}/g, (a, b) => p[b] || '').trim();
if (cropValues === undefined) {
// const warning = `WARNING: "${id}" not found in sprite file`;
sprites[id] = cropValueDefault; // or return cropValueDefault;
}
return sprites[id];
};
} else if (cropValues === undefined) {
// const warning = `WARNING: "${stop[1]}" not found in sprite file`;
cropValues = cropValueDefault;
}
return [stop[0], cropValues];
})
};
style.icon.cropValues = iconCropValue;
} else {
style.icon.cropValues = sprites[iconImg];
if (iconImg.includes('{')) {
style.icon.cropValues = function (p) {
const id = iconImg.replace(/\{(.+?)\}/g, (a, b) => p[b] || '').trim();
if (sprites[id] === undefined) {
// const warning = `WARNING: "${id}" not found in sprite file`;
sprites[id] = cropValueDefault; // or return cropValueDefault;
}
return sprites[id];
};
} else if (sprites[iconImg] === undefined) {
// const warning = `WARNING: "${iconImg}" not found in sprite file`;
style.icon.cropValues = cropValueDefault;
}
}
style.icon.source = sprites.source;
style.icon.size = readVectorProperty(layer.layout['icon-size']) ?? 1;
const {
color,
opacity
} = rgba2rgb(readVectorProperty(layer.paint['icon-color'], {
type: 'color'
}));
// https://docs.mapbox.com/style-spec/reference/layers/#paint-symbol-icon-color
if (iconImg.sdf) {
style.icon.color = color;
}
style.icon.opacity = readVectorProperty(layer.paint['icon-opacity']) ?? (opacity !== undefined && opacity);
} catch (err) {
err.message = `VTlayer '${layer.id}': argument sprites must not be null when using layer.layout['icon-image']`;
throw err;
}
}
}
// VectorTileSet: by default minZoom = 0 and maxZoom = 24
// https://docs.mapbox.com/style-spec/reference/layers/#maxzoom and #minzoom
// Should be move to layer properties, when (if) one mapBox layer will be considered as several itowns layers.
// issue https://github.com/iTowns/itowns/issues/2153 (last point)
style.zoom = {
min: layer.minzoom || 0,
max: layer.maxzoom || 24
};
return style;
}
export default {
setFromProperties,
setFromVectorTileLayer
};