diff --git a/src/Converter/Feature2Texture.js b/src/Converter/Feature2Texture.js index 3b56a883f7..ebece626fb 100644 --- a/src/Converter/Feature2Texture.js +++ b/src/Converter/Feature2Texture.js @@ -2,10 +2,23 @@ import * as THREE from 'three'; import { FEATURE_TYPES } from 'Core/Feature'; import Extent from 'Core/Geographic/Extent'; import Coordinates from 'Core/Geographic/Coordinates'; - -const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -const matrix = svg.createSVGMatrix(); - +import Style from 'Core/Style'; + +/** + * Draw polygon (contour, line edge and fill) based on feature vertices into canvas + * using the given style(s). Several styles will re-draws the polygon each one with + * a different style. + * @param {CanvasRenderingContext2D} ctx - canvas' 2D rendering context. + * @param {Number[]} vertices - All the vertices of the Feature. + * @param {Object[]} indices - Contains the indices that define the geometry. + * Objects stored in this array have two properties, an `offset` and a `count`. +* The offset is related to the overall number of vertices in the Feature. + * @param {Object} style - object defining the style of the polygon. + * @param {Number} size - The size of the feature. + * @param {Number} extent - The extent. + * @param {Number} invCtxScale - The ration to scale line width and radius circle. + * @param {Boolean} canBeFilled - true if feature.type == FEATURE_TYPES.POLYGON + */ function drawPolygon(ctx, vertices, indices = [{ offset: 0, count: 1 }], style = {}, size, extent, invCtxScale, canBeFilled) { if (vertices.length === 0) { return; @@ -22,63 +35,19 @@ function drawPolygon(ctx, vertices, indices = [{ offset: 0, count: 1 }], style = function _drawPolygon(ctx, vertices, indices, style, size, extent, invCtxScale, canBeFilled) { // build contour - ctx.beginPath(); + const path = new Path2D(); + for (const indice of indices) { if (indice.extent && Extent.intersectsExtent(indice.extent, extent)) { const offset = indice.offset * size; const count = offset + indice.count * size; - ctx.moveTo(vertices[offset], vertices[offset + 1]); + path.moveTo(vertices[offset], vertices[offset + 1]); for (let j = offset + size; j < count; j += size) { - ctx.lineTo(vertices[j], vertices[j + 1]); + path.lineTo(vertices[j], vertices[j + 1]); } } } - - // draw line or edge of polygon - if (style.stroke) { - strokeStyle(style, ctx, invCtxScale); - ctx.stroke(); - } - - // fill polygon only - if (canBeFilled && style.fill) { - fillStyle(style, ctx, invCtxScale); - ctx.fill(); - } -} - -function fillStyle(style, ctx, invCtxScale) { - if (style.fill.pattern && ctx.fillStyle.src !== style.fill.pattern.src) { - ctx.fillStyle = ctx.createPattern(style.fill.pattern, 'repeat'); - if (ctx.fillStyle.setTransform) { - ctx.fillStyle.setTransform(matrix.scale(invCtxScale)); - } else { - console.warn('Raster pattern isn\'t completely supported on Ie and edge'); - } - } else if (ctx.fillStyle !== style.fill.color) { - ctx.fillStyle = style.fill.color; - } - if (style.fill.opacity !== ctx.globalAlpha) { - ctx.globalAlpha = style.fill.opacity; - } -} - -function strokeStyle(style, ctx, invCtxScale) { - if (ctx.strokeStyle !== style.stroke.color) { - ctx.strokeStyle = style.stroke.color; - } - const width = (style.stroke.width || 2.0) * invCtxScale; - if (ctx.lineWidth !== width) { - ctx.lineWidth = width; - } - const alpha = style.stroke.opacity == undefined ? 1.0 : style.stroke.opacity; - if (alpha !== ctx.globalAlpha && typeof alpha == 'number') { - ctx.globalAlpha = alpha; - } - if (ctx.lineCap !== style.stroke.lineCap) { - ctx.lineCap = style.stroke.lineCap; - } - ctx.setLineDash(style.stroke.dasharray.map(a => a * invCtxScale * 2)); + Style.prototype.applyToCanvasPolygon.call(style, ctx, path, invCtxScale, canBeFilled); } function drawPoint(ctx, x, y, style = {}, invCtxScale) { diff --git a/src/Core/Label.js b/src/Core/Label.js index d53155e1be..d1818b0518 100644 --- a/src/Core/Label.js +++ b/src/Core/Label.js @@ -49,12 +49,14 @@ class Label extends THREE.Object3D { * is applied, it cannot be changed directly. However, if it really needed, * it can be accessed through `label.content.style`, but it is highly * discouraged to do so. - * @param {Object} [sprites] the sprites. */ - constructor(content = '', coordinates, style = {}, sprites) { + constructor(content = '', coordinates, style = {}) { if (coordinates == undefined) { throw new Error('coordinates are mandatory to add a Label'); } + if (arguments.length > 3) { + console.warn('Deprecated argument sprites in Label constructor. Sprites must be configured in style argument.'); + } super(); @@ -97,14 +99,18 @@ class Label extends THREE.Object3D { if (style.text.haloWidth > 0) { this.content.classList.add('itowns-stroke-single'); } - style.applyToHTML(this.content, sprites); + style.applyToHTML(this.content) + .then((icon) => { + if (icon) { // Not sure if that test is needed... + this.icon = icon; + } + }); } } else { this.anchor = [0, 0]; this.styleOffset = [0, 0]; } - this.icon = this.content.getElementsByClassName('itowns-icon')[0]; this.iconOffset = { left: 0, right: 0, top: 0, bottom: 0 }; this.zoom = { diff --git a/src/Core/Style.js b/src/Core/Style.js index a4ea89dc34..dcd49ca431 100644 --- a/src/Core/Style.js +++ b/src/Core/Style.js @@ -9,6 +9,9 @@ import itowns_stroke_single_before from './StyleChunk/itowns_stroke_single_befor export const cacheStyle = new Cache(); +const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); +const matrix = svg.createSVGMatrix(); + const inv255 = 1 / 255; const canvas = (typeof document !== 'undefined') ? document.createElement('canvas') : {}; const style_properties = {}; @@ -79,44 +82,44 @@ function readVectorProperty(property, options) { } } -function getImage(source, value) { - const target = document.createElement('img'); - - if (typeof source == 'string') { - if (value) { - const color = new Color(value); - Fetcher.texture(source, { crossOrigin: 'anonymous' }) - .then((texture) => { - const img = texture.image; - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - ctx.drawImage(img, 0, 0); - const imgd = ctx.getImageData(0, 0, img.naturalWidth, img.naturalHeight); - const pix = imgd.data; - - const colorToChange = new Color('white'); - for (let i = 0, n = pix.length; i < n; i += 4) { - const d = deltaE(pix.slice(i, i + 3), colorToChange) / 100; - pix[i] = (pix[i] * d + color.r * 255 * (1 - d)); - pix[i + 1] = (pix[i + 1] * d + color.g * 255 * (1 - d)); - pix[i + 2] = (pix[i + 2] * d + color.b * 255 * (1 - d)); - } - ctx.putImageData(imgd, 0, 0); - target.src = canvas.toDataURL('image/png'); - }); - } else { - target.src = source; - } - } else if (source && source[value]) { - const sprite = source[value]; - canvas.width = sprite.width; - canvas.height = sprite.height; - canvas.getContext('2d').drawImage(source.img, sprite.x, sprite.y, sprite.width, sprite.height, 0, 0, sprite.width, sprite.height); - target.src = canvas.toDataURL('image/png'); +async function loadImage(source) { + let promise = cacheStyle.get(source, 'null'); + if (!promise) { + promise = Fetcher.texture(source, { crossOrigin: 'anonymous' }); + cacheStyle.set(promise, source, 'null'); } + return (await promise).image; +} - return target; +function cropImage(img, cropValues = { width: img.naturalWidth, height: img.naturalHeight }) { + canvas.width = cropValues.width; + canvas.height = cropValues.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, + cropValues.x || 0, cropValues.y || 0, cropValues.width, cropValues.height, + 0, 0, cropValues.width, cropValues.height); + return ctx.getImageData(0, 0, cropValues.width, cropValues.height); +} + +function replaceWhitePxl(imgd, color, id) { + if (!color) { + return imgd; + } + const imgdColored = cacheStyle.get(id, color); + if (!imgdColored) { + const pix = imgd.data; + const newColor = new Color(color); + const colorToChange = new Color('white'); + for (let i = 0, n = pix.length; i < n; i += 4) { + const d = deltaE(pix.slice(i, i + 3), colorToChange) / 100; + pix[i] = (pix[i] * d + newColor.r * 255 * (1 - d)); + pix[i + 1] = (pix[i + 1] * d + newColor.g * 255 * (1 - d)); + pix[i + 2] = (pix[i + 2] * d + newColor.b * 255 * (1 - d)); + } + cacheStyle.set(imgd, id, color); + return imgd; + } + return imgdColored; } const textAnchorPosition = { @@ -170,10 +173,16 @@ function defineStyleProperty(style, category, name, value, defaultValue) { * 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|function} [fill.pattern] - Defines a pattern to fill the - * surface with. It can be an `Image` to use directly, or an url to fetch the pattern + * @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 {string} [fill.pattern.source] 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. @@ -277,12 +286,13 @@ function defineStyleProperty(style, category, name, value, defaultValue) { * * @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.key] - The key of the icons' image in a vector tile data set. + * @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` or - * `icon.key`, its size when displayed on screen is multiplied by `icon.size`. Default is `1`. + * @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. @@ -436,12 +446,13 @@ export class StyleOptions {} * * @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.key - The key of the icons' image in a vector tile data set. + * @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` or - * `icon.key`, its size when displayed on screen is multiplied by `icon.size`. Default is `1`. + * @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. @@ -505,12 +516,6 @@ class Style { defineStyleProperty(this, 'fill', 'base_altitude', params.fill.base_altitude, base_altitudeDefault); defineStyleProperty(this, 'fill', 'extrusion_height', params.fill.extrusion_height); - if (typeof this.fill.pattern == 'string') { - Fetcher.texture(this.fill.pattern).then((pattern) => { - this.fill.pattern = pattern.image; - }); - } - this.stroke = {}; defineStyleProperty(this, 'stroke', 'color', params.stroke.color); defineStyleProperty(this, 'stroke', 'opacity', params.stroke.opacity, 1.0); @@ -549,7 +554,12 @@ class Style { this.icon = {}; defineStyleProperty(this, 'icon', 'source', params.icon.source); - defineStyleProperty(this, 'icon', 'key', params.icon.key); + if (params.icon.key) { + console.warn("'icon.key' is deprecated: use 'icon.id' instead"); + params.icon.id = params.icon.key; + } + defineStyleProperty(this, 'icon', 'id', params.icon.id); + defineStyleProperty(this, 'icon', 'cropValues', params.icon.cropValues); defineStyleProperty(this, 'icon', 'anchor', params.icon.anchor, 'center'); defineStyleProperty(this, 'icon', 'size', params.icon.size, 1); defineStyleProperty(this, 'icon', 'color', params.icon.color); @@ -626,6 +636,7 @@ class Style { * set Style from (geojson-like) properties. * @param {object} properties (geojson-like) properties. * @param {number} type + * * @returns {StyleOptions} containing all properties for itowns.Style */ setFromGeojsonProperties(properties, type) { @@ -667,6 +678,7 @@ class Style { * @param {Object} sprites vector tile layer. * @param {number} [order=0] * @param {boolean} [symbolToCircle=false] + * * @returns {StyleOptions} containing all properties for itowns.Style */ setFromVectorTileLayer(layer, sprites, order = 0, symbolToCircle = false) { @@ -679,8 +691,17 @@ class Style { const { color, opacity } = rgba2rgb(readVectorProperty(layer.paint['fill-color'] || layer.paint['fill-pattern'], { type: 'color' })); this.fill.color = color; this.fill.opacity = readVectorProperty(layer.paint['fill-opacity']) || opacity; - if (layer.paint['fill-pattern'] && sprites) { - this.fill.pattern = getImage(sprites, layer.paint['fill-pattern']); + if (layer.paint['fill-pattern']) { + try { + this.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']) { @@ -743,26 +764,105 @@ class Style { } // additional icon - const key = readVectorProperty(layer.layout['icon-image']); - if (key) { - this.icon.key = key; - this.icon.size = readVectorProperty(layer.layout['icon-size']) || 1; - const { color, opacity } = rgba2rgb(readVectorProperty(layer.paint['icon-color'], { type: 'color' })); - this.icon.color = color; - this.icon.opacity = readVectorProperty(layer.paint['icon-opacity']) || (opacity !== undefined && opacity); + const iconImg = readVectorProperty(layer.layout['icon-image']); + if (iconImg) { + try { + this.icon.id = iconImg; + this.icon.source = sprites.source; + this.icon.cropValues = sprites[iconImg]; + + this.icon.size = readVectorProperty(layer.layout['icon-size']) || 1; + const { color, opacity } = rgba2rgb(readVectorProperty(layer.paint['icon-color'], { type: 'color' })); + this.icon.color = color; + this.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; + } } } return this; } + /** + * Applies the style.fill to a polygon of the texture canvas. + * @param {CanvasRenderingContext2D} txtrCtx The Context 2D of the texture canvas. + * @param {Path2D} polygon The current texture canvas polygon. + * @param {Number} invCtxScale The ratio to scale line width and radius circle. + * @param {Boolean} canBeFilled - true if feature.type == FEATURE_TYPES.POLYGON. + */ + applyToCanvasPolygon(txtrCtx, polygon, invCtxScale, canBeFilled) { + // draw line or edge of polygon + if (this.stroke) { + // TO DO add possibility of using a pattern (https://github.com/iTowns/itowns/issues/2210) + Style.prototype._applyStrokeToPolygon.call(this, txtrCtx, invCtxScale, polygon); + } + + // fill inside of polygon + if (canBeFilled && this.fill) { + // canBeFilled can be move to StyleContext in the later PR + Style.prototype._applyFillToPolygon.call(this, txtrCtx, invCtxScale, polygon); + } + } + + _applyStrokeToPolygon(txtrCtx, invCtxScale, polygon) { + if (txtrCtx.strokeStyle !== this.stroke.color) { + txtrCtx.strokeStyle = this.stroke.color; + } + const width = (this.stroke.width || 2.0) * invCtxScale; + if (txtrCtx.lineWidth !== width) { + txtrCtx.lineWidth = width; + } + const alpha = this.stroke.opacity == undefined ? 1.0 : this.stroke.opacity; + if (alpha !== txtrCtx.globalAlpha && typeof alpha == 'number') { + txtrCtx.globalAlpha = alpha; + } + if (txtrCtx.lineCap !== this.stroke.lineCap) { + txtrCtx.lineCap = this.stroke.lineCap; + } + txtrCtx.setLineDash(this.stroke.dasharray.map(a => a * invCtxScale * 2)); + txtrCtx.stroke(polygon); + } + + async _applyFillToPolygon(txtrCtx, invCtxScale, polygon) { + // if (this.fill.pattern && txtrCtx.fillStyle.src !== this.fill.pattern.src) { + // need doc for the txtrCtx.fillStyle.src that seems to always be undefined + if (this.fill.pattern) { + let img = this.fill.pattern; + if (this.fill.pattern.source) { + img = await loadImage(this.fill.pattern.source); + } + cropImage(img, this.fill.pattern.cropValues); + + txtrCtx.fillStyle = txtrCtx.createPattern(canvas, 'repeat'); + if (txtrCtx.fillStyle.setTransform) { + txtrCtx.fillStyle.setTransform(matrix.scale(invCtxScale)); + } else { + console.warn('Raster pattern isn\'t completely supported on Ie and edge', txtrCtx.fillStyle); + } + } else if (txtrCtx.fillStyle !== this.fill.color) { + txtrCtx.fillStyle = this.fill.color; + } + if (this.fill.opacity !== txtrCtx.globalAlpha) { + txtrCtx.globalAlpha = this.fill.opacity; + } + txtrCtx.fill(polygon); + } + /** * Applies this style to a DOM element. Limited to the `text` and `icon` * properties of this style. * * @param {Element} domElement - The element to set the style to. - * @param {Object} sprites - the sprites. + * + * @returns {undefined|Promise} + * for a text label: undefined. + * for an icon: a Promise resolving with the HTMLImageElement containing the image. */ - applyToHTML(domElement, sprites) { + async applyToHTML(domElement) { + if (arguments.length > 1) { + console.warn('Deprecated argument sprites. Sprites must be configured in style.'); + } domElement.style.padding = `${this.text.padding}px`; domElement.style.maxWidth = `${this.text.wrap}em`; @@ -784,83 +884,79 @@ class Style { domElement.setAttribute('data-before', domElement.textContent); } - if (!this.icon.source && !this.icon.key) { + if (!this.icon.source) { return; } - const image = this.icon.source; - const size = this.icon.size; - const key = this.icon.key; - const color = this.icon.color; + const icon = document.createElement('img'); - let icon = cacheStyle.get(image || key, size, color); + const iconPromise = new Promise((resolve, reject) => { + icon.onload = () => resolve(this._addIcon(icon, domElement)); + icon.onerror = err => reject(err); + }); - if (!icon) { - if (key && sprites) { - icon = getImage(sprites, key); - } else { - icon = getImage(image, color); - } - cacheStyle.set(icon, image || key, size, color); + if (!this.icon.cropValues && !this.icon.color) { + icon.src = this.icon.source; + } else { + const img = await loadImage(this.icon.source); + const imgd = cropImage(img, this.icon.cropValues); + const imgdColored = replaceWhitePxl(imgd, this.icon.color, this.icon.id || this.icon.source); + canvas.getContext('2d').putImageData(imgdColored, 0, 0); + icon.src = canvas.toDataURL('image/png'); } + return iconPromise; + } - const addIcon = () => { - const cIcon = icon.cloneNode(); - - cIcon.setAttribute('class', 'itowns-icon'); - - cIcon.width = icon.width * this.icon.size; - cIcon.height = icon.height * this.icon.size; - cIcon.style.color = this.icon.color; - cIcon.style.opacity = this.icon.opacity; - cIcon.style.position = 'absolute'; - cIcon.style.top = '0'; - cIcon.style.left = '0'; - - switch (this.icon.anchor) { // center by default - case 'left': - cIcon.style.top = `${-0.5 * cIcon.height}px`; - break; - case 'right': - cIcon.style.top = `${-0.5 * cIcon.height}px`; - cIcon.style.left = `${-cIcon.width}px`; - break; - case 'top': - cIcon.style.left = `${-0.5 * cIcon.width}px`; - break; - case 'bottom': - cIcon.style.top = `${-cIcon.height}px`; - cIcon.style.left = `${-0.5 * cIcon.width}px`; - break; - case 'bottom-left': - cIcon.style.top = `${-cIcon.height}px`; - break; - case 'bottom-right': - cIcon.style.top = `${-cIcon.height}px`; - cIcon.style.left = `${-cIcon.width}px`; - break; - case 'top-left': - break; - case 'top-right': - cIcon.style.left = `${-cIcon.width}px`; - break; - case 'center': - default: - cIcon.style.top = `${-0.5 * cIcon.height}px`; - cIcon.style.left = `${-0.5 * cIcon.width}px`; - break; - } - - cIcon.style['z-index'] = -1; - domElement.appendChild(cIcon); - icon.removeEventListener('load', addIcon); - }; - - if (icon.complete) { - addIcon(); - } else { - icon.addEventListener('load', addIcon); + _addIcon(icon, domElement) { + const cIcon = icon.cloneNode(); + + cIcon.setAttribute('class', 'itowns-icon'); + + cIcon.width = icon.width * this.icon.size; + cIcon.height = icon.height * this.icon.size; + cIcon.style.color = this.icon.color; + cIcon.style.opacity = this.icon.opacity; + cIcon.style.position = 'absolute'; + cIcon.style.top = '0'; + cIcon.style.left = '0'; + + switch (this.icon.anchor) { // center by default + case 'left': + cIcon.style.top = `${-0.5 * cIcon.height}px`; + break; + case 'right': + cIcon.style.top = `${-0.5 * cIcon.height}px`; + cIcon.style.left = `${-cIcon.width}px`; + break; + case 'top': + cIcon.style.left = `${-0.5 * cIcon.width}px`; + break; + case 'bottom': + cIcon.style.top = `${-cIcon.height}px`; + cIcon.style.left = `${-0.5 * cIcon.width}px`; + break; + case 'bottom-left': + cIcon.style.top = `${-cIcon.height}px`; + break; + case 'bottom-right': + cIcon.style.top = `${-cIcon.height}px`; + cIcon.style.left = `${-cIcon.width}px`; + break; + case 'top-left': + break; + case 'top-right': + cIcon.style.left = `${-cIcon.width}px`; + break; + case 'center': + default: + cIcon.style.top = `${-0.5 * cIcon.height}px`; + cIcon.style.left = `${-0.5 * cIcon.width}px`; + break; } + + cIcon.style['z-index'] = -1; + domElement.appendChild(cIcon); + return cIcon; } /** diff --git a/src/Layer/LabelLayer.js b/src/Layer/LabelLayer.js index 7d74d89740..dcc8e68e4d 100644 --- a/src/Layer/LabelLayer.js +++ b/src/Layer/LabelLayer.js @@ -288,7 +288,7 @@ class LabelLayer extends GeometryLayer { const style = (g.properties.style || f.style || this.style).symbolStylefromContext(context); - const label = new Label(content, coord.clone(), style, this.source.sprites); + const label = new Label(content, coord.clone(), style); label.layerId = this.id; label.padding = this.margin || label.padding; diff --git a/src/Layer/Layer.js b/src/Layer/Layer.js index ba4c5856f9..55651d1f96 100644 --- a/src/Layer/Layer.js +++ b/src/Layer/Layer.js @@ -99,6 +99,10 @@ class Layer extends THREE.EventDispatcher { } super(); if (config.style && !(config.style instanceof Style)) { + if (typeof config.style.fill?.pattern === 'string') { + console.warn('Using style.fill.pattern = { source: Img|url } is adviced'); + config.style.fill.pattern = { source: config.style.fill.pattern }; + } config.style = new Style(config.style); } this.isLayer = true; @@ -109,7 +113,6 @@ class Layer extends THREE.EventDispatcher { value: id, writable: false, }); - // Default properties this.options = config.options || {}; diff --git a/src/Source/VectorTilesSource.js b/src/Source/VectorTilesSource.js index 36db7e9f53..b2095f50c6 100644 --- a/src/Source/VectorTilesSource.js +++ b/src/Source/VectorTilesSource.js @@ -79,10 +79,8 @@ class VectorTilesSource extends TMSSource { return Fetcher.json(spriteUrl, this.networkOptions).then((sprites) => { this.sprites = sprites; const imgUrl = urlParser.normalizeSpriteURL(baseurl, '', '.png', this.accessToken); - return Fetcher.texture(imgUrl, this.networkOptions).then((texture) => { - this.sprites.img = texture.image; - return style; - }); + this.sprites.source = imgUrl; + return style; }); } @@ -108,7 +106,10 @@ class VectorTilesSource extends TMSSource { id: layer.id, order, filterExpression: featureFilter(layer.filter), - zoom: style.zoom, + zoom: { + min: layer.minzoom || 0, + max: layer.maxzoom || 24, + }, }); } }); diff --git a/test/unit/bootstrap.js b/test/unit/bootstrap.js index ec63468c58..a47017013b 100644 --- a/test/unit/bootstrap.js +++ b/test/unit/bootstrap.js @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import fetch from 'node-fetch'; global.window = { @@ -76,6 +77,29 @@ class DOMElement { // Mock HTMLDivElement for Mocha global.HTMLDivElement = DOMElement; +class HTMLImageElement extends DOMElement { + constructor(width, height) { + super(); + this.width = width || 10; + this.height = height || 10; + this.naturalWidth = this.width; + this.naturalHeight = this.height; + Object.defineProperty(this, 'src', { + set: () => { this.emitEvent('load'); }, + }); + } +} + +class CanvasPattern { + // eslint-disable-next-line no-unused-vars + setTransform(matrix) { return undefined; } +} + +class DOMMatrix { + // eslint-disable-next-line no-unused-vars + scale(matrix) { return [1, 1, 1, 1]; } +} + // Mock document object for Mocha. global.document = { createElement: (type) => { @@ -117,6 +141,11 @@ global.document = { image.height = imageData.sh; return image; }, + // eslint-disable-next-line no-unused-vars + createPattern: (image, repetition) => { + const canvasPattern = new CanvasPattern(); + return canvasPattern; + }, canvas, }); @@ -124,13 +153,12 @@ global.document = { return canvas; } else if (type == 'img') { - const img = new DOMElement(); - img.width = 10; - img.height = 10; - Object.defineProperty(img, 'src', { - set: () => { img.emitEvent('load'); }, - }); + const img = new HTMLImageElement(); return img; + } else if (type == 'svg') { + const svg = new DOMElement(); + svg.createSVGMatrix = () => new DOMMatrix(); + return svg; } return new DOMElement(); @@ -141,6 +169,13 @@ global.document = { global.document.documentElement = global.document.createElement(); +class Path2D { + moveTo() {} + lineTo() {} +} + +global.Path2D = Path2D; + class Renderer { constructor() { this.domElement = new DOMElement(); diff --git a/test/unit/label.js b/test/unit/label.js index c7d77e02b5..b7ef957a29 100644 --- a/test/unit/label.js +++ b/test/unit/label.js @@ -1,7 +1,7 @@ import assert from 'assert'; import * as THREE from 'three'; import Label from 'Core/Label'; -import Style, { cacheStyle } from 'Core/Style'; +import Style from 'Core/Style'; import { FeatureCollection, FEATURE_TYPES } from 'Core/Feature'; import Coordinates from 'Core/Geographic/Coordinates'; import Extent from 'Core/Geographic/Extent'; @@ -77,53 +77,6 @@ describe('Label', function () { assert.doesNotThrow(() => { label = new Label(document.createElement('div'), c); }); }); - it('should set the correct icon anchor position', function () { - label = new Label('', c, style, sprites); - - // Mock async loading image - const img = cacheStyle.get('icon', 1); - img.complete = true; - img.emitEvent('load'); - assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`); - assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`); - - - style.icon.anchor = 'left'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, '0'); - assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`); - - style.icon.anchor = 'right'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, `${-img.width}px`); - assert.equal(label.content.children[0].style.top, `${-0.5 * img.height}px`); - - style.icon.anchor = 'top'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`); - assert.equal(label.content.children[0].style.top, '0'); - - style.icon.anchor = 'bottom'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, `${-0.5 * img.width}px`); - assert.equal(label.content.children[0].style.top, `${-img.height}px`); - - style.icon.anchor = 'bottom-left'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, '0'); - assert.equal(label.content.children[0].style.top, `${-img.height}px`); - - style.icon.anchor = 'bottom-right'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, `${-img.width}px`); - assert.equal(label.content.children[0].style.top, `${-img.height}px`); - - style.icon.anchor = 'top-left'; - label = new Label('', c, style); - assert.equal(label.content.children[0].style.left, '0'); - assert.equal(label.content.children[0].style.top, '0'); - }); - it('should hide the DOM', function () { label = new Label('', c, style); diff --git a/test/unit/style.js b/test/unit/style.js index 8b6ffbc84d..8193d17881 100644 --- a/test/unit/style.js +++ b/test/unit/style.js @@ -1,4 +1,4 @@ -import Style, { cacheStyle } from 'Core/Style'; +import Style from 'Core/Style'; import assert from 'assert'; import Fetcher from 'Provider/Fetcher'; import { TextureLoader } from 'three'; @@ -37,19 +37,56 @@ describe('Style', function () { }); it('Clone style', () => { - const styleClone = style.clone(style); + const styleClone = style.clone(); assert.equal(style.point.color, styleClone.point.color); assert.equal(style.fill.color, styleClone.fill.color); assert.equal(style.stroke.color, styleClone.stroke.color); }); - const sprites = { - img: '', - 'fill-pattern': { x: 0, y: 0, width: 10, height: 10 }, - }; + describe('applyToCanvasPolygon', () => { + const c = document.createElement('canvas'); + const txtrCtx = c.getContext('2d'); + describe('_applyStrokeToPolygon()', () => { + const invCtxScale = 0.75; + style.clone()._applyStrokeToPolygon(txtrCtx, invCtxScale); + assert.equal(txtrCtx.strokeStyle, style.stroke.color); + assert.equal(txtrCtx.lineWidth, style.stroke.width * invCtxScale); + assert.equal(txtrCtx.lineCap, style.stroke.lineCap); + assert.equal(txtrCtx.globalAlpha, style.stroke.opacity); + }); + describe('_applyFillToPolygon()', () => { + it('with fill.pattern = img', function (done) { + const invCtxScale = 1; + const polygon = new Path2D(); + const img = document.createElement('img'); + const style1 = style.clone(); + style1.fill.pattern = img; + style1.fill.opacity = 0.1; + style1._applyFillToPolygon(txtrCtx, invCtxScale, polygon) + .then(() => { + assert.equal(txtrCtx.fillStyle.constructor.name, 'CanvasPattern'); + assert.equal(txtrCtx.globalAlpha, style1.fill.opacity); + done(); + }).catch(done); + }); + it('with fill.color = #0500fd', function (done) { + const invCtxScale = 1; + const polygon = new Path2D(); + const style1 = style.clone(); + style1.fill.color = '#0500fd'; + style1.fill.opacity = 0.2; + style1._applyFillToPolygon(txtrCtx, invCtxScale, polygon) + .then(() => { + assert.equal(txtrCtx.fillStyle, '#0500fd'); + assert.equal(txtrCtx.globalAlpha, style1.fill.opacity); + done(); + }).catch(done); + }); + }); + }); describe('applyToHTML', () => { - it('with icon.source and icon.key undefined', () => { + it('with no icon.source', () => { const dom = document.createElement('canvas'); style.applyToHTML(dom); assert.equal(dom.style.padding, '2px'); @@ -65,45 +102,179 @@ describe('Style', function () { const sourceString = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/images/kml_circle.png'; describe('with icon.source (test getImage())', () => { - it('icon.source is string but icon.key is undefined ', () => { - const dom = document.createElement('canvas'); - const style1 = style.clone(style); - style1.icon = { - source: 'icon', - }; - style1.applyToHTML(dom); - const img = cacheStyle.get('icon', 1); - img.emitEvent('load'); - assert.equal(dom.children[0].class, 'itowns-icon'); - assert.equal(dom.children[0].style['z-index'], -1); + it('with icon.source as img', function (done) { + const dom = document.createElement('div'); + const img = document.createElement('img'); + const style1 = style.clone(); + style1.icon.source = img; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].class, 'itowns-icon'); + assert.equal(dom.children[0].style['z-index'], -1); + done(); + }).catch(done); }); - it('icon.source is string and icon.color=#0400fd', () => { - const dom = document.createElement('canvas'); - const style1 = style.clone(style); - style1.icon = { - source: sourceString, - color: '#0400fd', - }; - style1.applyToHTML(dom); - const img = cacheStyle.get(sourceString, 1); + it('with icon.source as string', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = sourceString; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].class, 'itowns-icon'); + assert.equal(dom.children[0].style['z-index'], -1); + done(); + }).catch(done); + }); + it('icon.source as string and icon.color=#0400fd', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = sourceString; + style1.icon.color = '#0400fd'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children.length, 1); + assert.equal(dom.children[0].class, 'itowns-icon'); + assert.equal(dom.children[0].style['z-index'], -1); + done(); + }).catch(done); + }); + it('icon.source and cropValues', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.id = 'fill-pattern'; + style1.icon.source = 'icon'; + style1.icon.cropValues = { x: 0, y: 0, width: 10, height: 10 }; - img.emitEvent('load'); - assert.equal(dom.children.length, 1); - assert.equal(dom.children[0].class, 'itowns-icon'); - assert.equal(dom.children[0].style['z-index'], -1); + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].class, 'itowns-icon'); + assert.equal(dom.children[0].style['z-index'], -1); + done(); + }).catch(done); + }); + }); + describe('icon anchor position (test addIcon())', () => { + it('icon.anchor is center', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-0.5 * icon.height}px`); + assert.equal(dom.children[0].style.left, `${-0.5 * icon.width}px`); + done(); + }).catch(done); }); - it('with icon.key and sprites', () => { - const dom = document.createElement('canvas'); - const style1 = style.clone(style); - style1.icon = { - key: 'fill-pattern', - }; + it('icon.anchor is left', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'left'; - style1.applyToHTML(dom, sprites); - const img = cacheStyle.get('fill-pattern', 1); - img.emitEvent('load'); - assert.equal(dom.children[0].class, 'itowns-icon'); - assert.equal(dom.children[0].style['z-index'], -1); + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-0.5 * icon.height}px`); + assert.equal(dom.children[0].style.left, 0); + done(); + }).catch(done); + }); + it('icon.anchor is right', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'right'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-0.5 * icon.height}px`); + assert.equal(dom.children[0].style.left, `${-icon.width}px`); + done(); + }).catch(done); + }); + it('icon.anchor is top', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'top'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, 0); + assert.equal(dom.children[0].style.left, `${-0.5 * icon.height}px`); + done(); + }).catch(done); + }); + it('icon.anchor is bottom', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'bottom'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-icon.height}px`); + assert.equal(dom.children[0].style.left, `${-0.5 * icon.width}px`); + done(); + }).catch(done); + }); + it('icon.anchor is bottom-left', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'bottom-left'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-icon.height}px`); + assert.equal(dom.children[0].style.left, 0); + + done(); + }).catch(done); + }); + it('icon.anchor is bottom-right', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'bottom-right'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, `${-icon.height}px`); + assert.equal(dom.children[0].style.left, `${-icon.width}px`); + done(); + }).catch(done); + }); + it('icon.anchor is top-left', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'top-left'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, 0); + assert.equal(dom.children[0].style.left, 0); + done(); + }).catch(done); + }); + it('icon.anchor is top-right', function (done) { + const dom = document.createElement('div'); + const style1 = style.clone(); + style1.icon.source = 'icon'; + style1.icon.anchor = 'top-right'; + style1.applyToHTML(dom) + .then((icon) => { + icon.emitEvent('load'); + assert.equal(dom.children[0].style.top, 0); + assert.equal(dom.children[0].style.left, `${-icon.width}px`); + done(); + }).catch(done); }); }); });