From 543110868ddb705c1a56a3a4875aa9b24e2b0935 Mon Sep 17 00:00:00 2001 From: mgermerie <73115044+mgermerie@users.noreply.github.com> Date: Thu, 16 Sep 2021 14:31:04 +0200 Subject: [PATCH] feat(ColorLayer): points can now be displayed as icons --- examples/images/arrow.svg | 49 ++++++++++++++++++++++ examples/source_file_shapefile.html | 18 ++++++++- src/Converter/Feature2Texture.js | 11 +++-- src/Converter/textureConverter.js | 2 +- src/Core/Label.js | 6 +++ src/Core/Style.js | 46 ++++++++++++++------- src/Core/View.js | 63 +++++++++++++++++------------ src/Layer/LabelLayer.js | 40 ++++++++++-------- src/Renderer/Label2DRenderer.js | 16 +++++--- test/unit/label.js | 10 +++++ 10 files changed, 191 insertions(+), 70 deletions(-) create mode 100644 examples/images/arrow.svg diff --git a/examples/images/arrow.svg b/examples/images/arrow.svg new file mode 100644 index 0000000000..b2a449d43d --- /dev/null +++ b/examples/images/arrow.svg @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/examples/source_file_shapefile.html b/examples/source_file_shapefile.html index 858626f903..1c5b591519 100644 --- a/examples/source_file_shapefile.html +++ b/examples/source_file_shapefile.html @@ -86,15 +86,29 @@ source: velibSource, style: new itowns.Style({ zoom: { min: 10, max: 20 }, - point: { color: 'white', line: 'green' }, + point: { + color: 'white', + line: 'green', + radius: 5, + width: 2, + }, + icon: { + source: 'images/arrow.svg', + anchor: 'top-left', + size: 2, + }, text: { field: '{name}\n(id: {station_id})', size: 14, haloColor: 'white', haloWidth: 1, font: ['monospace'], - } + anchor: 'top-left', + justify: 'left', + offset: [20, 18], + }, }), + displayAsIcon: true, addLabelLayer: true, })); }).then((layer => { diff --git a/src/Converter/Feature2Texture.js b/src/Converter/Feature2Texture.js index a531ceeba7..e366453517 100644 --- a/src/Converter/Feature2Texture.js +++ b/src/Converter/Feature2Texture.js @@ -102,7 +102,7 @@ function drawPoint(ctx, x, y, style = {}, invCtxScale) { const coord = new Coordinates('EPSG:4326', 0, 0, 0); -function drawFeature(ctx, feature, extent, style, invCtxScale) { +function drawFeature(ctx, feature, extent, style, invCtxScale, layer) { const extentDim = extent.planarDimensions(); const scaleRadius = extentDim.x / ctx.canvas.width; const globals = { zoom: extent.zoom }; @@ -114,6 +114,9 @@ function drawFeature(ctx, feature, extent, style, invCtxScale) { if (contextStyle) { if (feature.type === FEATURE_TYPES.POINT) { + // Don't display point as raster if they are already displayed as icons. + if (layer.displayAsIcon) { continue; } + // cross multiplication to know in the extent system the real size of // the point const px = (Math.round(contextStyle.point.radius * invCtxScale) || 3 * invCtxScale) * scaleRadius; @@ -148,7 +151,7 @@ const featureExtent = new Extent('EPSG:4326', 0, 0, 0, 0); export default { // backgroundColor is a THREE.Color to specify a color to fill the texture // with, given there is no feature passed in parameter - createTextureFromFeature(collection, extent, sizeTexture, style, backgroundColor) { + createTextureFromFeature(collection, extent, sizeTexture, layer, backgroundColor) { let texture; if (collection) { @@ -166,7 +169,7 @@ export default { ctx.fillStyle = backgroundColor.getStyle(); ctx.fillRect(0, 0, sizeTexture, sizeTexture); } - ctx.globalCompositeOperation = style.globalCompositeOperation || 'source-over'; + ctx.globalCompositeOperation = layer.style.globalCompositeOperation || 'source-over'; ctx.imageSmoothingEnabled = false; ctx.lineJoin = 'round'; @@ -197,7 +200,7 @@ export default { // Draw the canvas for (const feature of collection.features) { - drawFeature(ctx, feature, featureExtent, feature.style || style, invCtxScale); + drawFeature(ctx, feature, featureExtent, feature.style || layer.style, invCtxScale, layer); } texture = new THREE.CanvasTexture(c); diff --git a/src/Converter/textureConverter.js b/src/Converter/textureConverter.js index 7821274cff..32123bb637 100644 --- a/src/Converter/textureConverter.js +++ b/src/Converter/textureConverter.js @@ -28,7 +28,7 @@ export default { undefined; extentDestination.as(CRS.formatToEPSG(layer.crs), extentTexture); - texture = Feature2Texture.createTextureFromFeature(data, extentTexture, 256, layer.style, backgroundColor); + texture = Feature2Texture.createTextureFromFeature(data, extentTexture, 256, layer, backgroundColor); texture.features = data; texture.extent = extentDestination; } else if (data.isTexture) { diff --git a/src/Core/Label.js b/src/Core/Label.js index 468362c06c..0cc117e48b 100644 --- a/src/Core/Label.js +++ b/src/Core/Label.js @@ -88,6 +88,12 @@ class Label extends THREE.Object3D { this.content = content.cloneNode(true); } + // Display labels with content (either text or domElement) on top of content-less labels (such as labels with + // only an icon for instance). + if (content !== '') { + this.content.style.zIndex = '1'; + } + this.content.classList.add('itowns-label'); this.content.style.userSelect = 'none'; this.content.style.position = 'absolute'; diff --git a/src/Core/Style.js b/src/Core/Style.js index c158a8d9d0..2f50d53ef0 100644 --- a/src/Core/Style.js +++ b/src/Core/Style.js @@ -367,6 +367,7 @@ class Style { defineStyleProperty(this, 'text', 'haloBlur', params.text.haloBlur, 0); this.icon = {}; + defineStyleProperty(this, 'icon', 'pointIcon', params.icon.pointIcon); defineStyleProperty(this, 'icon', 'source', params.icon.source); defineStyleProperty(this, 'icon', 'key', params.icon.key); defineStyleProperty(this, 'icon', 'anchor', params.icon.anchor, 'center'); @@ -406,9 +407,8 @@ class Style { symbolStylefromContext(context) { const style = new Style(); mapPropertiesFromContext('text', this, style, context); - if (this.icon) { - mapPropertiesFromContext('icon', this, style, context); - } + mapPropertiesFromContext('icon', this, style, context); + mapPropertiesFromContext('point', this, style, context); style.order = this.order; return style; } @@ -592,23 +592,41 @@ class Style { domElement.setAttribute('data-before', domElement.textContent); } - if (!this.icon.source && !this.icon.key) { + if (!this.icon.pointIcon && !this.icon.source && !this.icon.key) { return; } - const image = this.icon.source; - const size = this.icon.size; - const key = this.icon.key; + let icon; - let icon = cacheStyle.get(image || key, size); + if (this.icon.pointIcon) { + icon = this.icon.pointIcon; - if (!icon) { - if (key && sprites) { - icon = getImage(sprites, key); - } else { - icon = getImage(image); + icon.setAttribute('class', 'itowns-icon'); + + icon.width = 2 * (this.point.radius + this.point.width); + icon.height = icon.width; + + icon.style.width = 2 * this.point.radius; + icon.style.height = icon.style.width; + icon.style.backgroundColor = this.point.color; + icon.style.border = `${this.point.width}px solid ${this.point.line}`; + icon.style.borderRadius = '50%'; + + icon.complete = true; + } else { + const image = this.icon.source; + const size = this.icon.size; + const key = this.icon.key; + + icon = cacheStyle.get(image || key, size); + if (!icon) { + if (key && sprites) { + icon = getImage(sprites, key); + } else { + icon = getImage(image); + } + cacheStyle.set(icon, image || key, size); } - cacheStyle.set(icon, image || key, size); } const addIcon = () => { diff --git a/src/Core/View.js b/src/Core/View.js index 37eeadd03f..c75f58f2d9 100644 --- a/src/Core/View.js +++ b/src/Core/View.js @@ -83,39 +83,50 @@ function _preprocessLayer(view, layer, parentLayer) { if (layer.isLabelLayer) { view.mainLoop.gfxEngine.label2dRenderer.registerLayer(layer); - } else if (layer.labelEnabled || layer.addLabelLayer) { - if (layer.labelEnabled) { - // eslint-disable-next-line no-console - console.info('layer.labelEnabled is deprecated use addLabelLayer, instead of'); + } else if (layer.labelEnabled || layer.addLabelLayer || layer.displayAsIcon) { + if (layer.labelEnabled !== undefined) { + console.warn('layer.labelEnabled is deprecated use addLabelLayer, instead of'); + layer.addLabelLayer = layer.labelEnabled; } // Because the features are shared between layer and labelLayer. layer.buildExtent = true; - const labelLayer = new LabelLayer(`${layer.id}-label`, { - source, - style: layer.style, - zoom: layer.zoom, - crs: source.crs, - visible: layer.visible, - margin: 15, - }); - layer.addEventListener('visible-property-changed', () => { - labelLayer.visible = layer.visible; - }); + const addLabelLayer = (pointOrLabel) => { + const labelLayer = new LabelLayer(`${layer.id}-${pointOrLabel}`, { + source, + style: layer.style, + zoom: layer.zoom, + crs: source.crs, + visible: layer.visible, + margin: 15, + displayPoints: pointOrLabel === 'point', + }); - const removeLabelLayer = (e) => { - if (e.layerId === layer.id) { - view.removeLayer(labelLayer.id); - } - view.removeEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer); - }; + layer.addEventListener('visible-property-changed', () => { + labelLayer.visible = layer.visible; + }); + + const removeLabelLayer = (e) => { + if (e.layerId === layer.id) { + view.removeLayer(labelLayer.id); + } + view.removeEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer); + }; - view.addEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer); + view.addEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer); - layer.whenReady = layer.whenReady.then(() => { - view.addLayer(labelLayer); - return layer; - }); + layer.whenReady = layer.whenReady.then(() => { + view.addLayer(labelLayer); + return layer; + }); + }; + + if (layer.addLabelLayer) { + addLabelLayer('label'); + } + if (layer.displayAsIcon) { + addLabelLayer('point'); + } } return layer; diff --git a/src/Layer/LabelLayer.js b/src/Layer/LabelLayer.js index 90c2aba640..e32834c834 100644 --- a/src/Layer/LabelLayer.js +++ b/src/Layer/LabelLayer.js @@ -12,6 +12,9 @@ const coord = new Coordinates('EPSG:4326', 0, 0, 0); const _extent = new Extent('EPSG:4326', 0, 0, 0, 0); +const divDomElement = document.createElement('div'); + + /** * A layer to handle a bunch of `Label`. This layer can be created on its own, * but it is better to use the option `addLabelLayer` on another `Layer` to let @@ -57,12 +60,6 @@ class LabelLayer extends Layer { this.buildExtent = true; this.labelDomelement = config.domElement; - - // The margin property defines a space around each label that cannot be occupied by another label. - // For example, if some labelLayer has a margin value of 5, there will be at least 10 pixels - // between each labels of the layer - // TODO : this property should be moved to Style after refactoring style properties structure - this.margin = config.margin; } /** @@ -85,7 +82,7 @@ class LabelLayer extends Layer { convert(data, extent) { const labels = []; - const layerField = this.style && this.style.text && this.style.text.field; + const layerField = this.style?.text?.field; // Converting the extent now is faster for further operation extent.as(data.crs, _extent); @@ -98,7 +95,7 @@ class LabelLayer extends Layer { return; } - const featureField = f.style && f.style.text.field; + const featureField = f.style?.text.field; f.geometries.forEach((g) => { // NOTE: this only works because only POINT is supported, it @@ -110,16 +107,23 @@ class LabelLayer extends Layer { if (f.size == 2) { coord.z = 0; } if (!_extent.isPointInside(coord)) { return; } - const geometryField = g.properties.style && g.properties.style.text.field; + const geometryField = g.properties.style?.text.field; let content; const context = { globals, properties: () => g.properties }; - if (this.labelDomelement) { + + const style = (g.properties.style || f.style || this.style).symbolStylefromContext(context); + + if (this.displayPoints) { + style.icon.pointIcon = divDomElement; + style.icon.size = 1; + style.icon.anchor = 'center'; + } else if (this.labelDomelement) { content = readExpression(this.labelDomelement, context); } else if (!geometryField && !featureField && !layerField) { // Check if there is an icon, with no text - if (!(g.properties.style && (g.properties.style.icon.source || g.properties.style.icon.key)) - && !(f.style && (f.style.icon.source || f.style.icon.key)) - && !(this.style && (this.style.icon.source || this.style.icon.key))) { + if (!(g.properties.style?.icon.source || g.properties.style?.icon.key) + && !(f.style?.icon.source || f.style?.icon.key) + && !(this.style?.icon.source || this.style?.icon.key)) { return; } } else if (geometryField) { @@ -130,11 +134,10 @@ class LabelLayer extends Layer { content = this.style.getTextFromProperties(context); } - const style = (g.properties.style || f.style || this.style).symbolStylefromContext(context); - const label = new Label(content, coord.clone(), style, this.source.sprites); label.layerId = this.id; - label.padding = this.margin || label.padding; + label.padding = this.margin ?? label.padding; + label.allowOverlapping = this.displayPoints; if (f.size == 2) { label.needsAltitude = true; @@ -218,7 +221,10 @@ class LabelLayer extends Layer { label.updateElevationFromLayer(this.parent); } - const present = node.children.find(l => l.isLabel && l.baseContent == label.baseContent); + // TODO : this implies displaying every icon with no baseContent (default to ''). Therefore we will + // display all duplicate icons. This might be improved. + const present = label.baseContent !== '' + && node.children.find(l => l.isLabel && l.baseContent === label.baseContent); if (!present) { node.add(label); diff --git a/src/Renderer/Label2DRenderer.js b/src/Renderer/Label2DRenderer.js index c698f7c4fe..b016b530ad 100644 --- a/src/Renderer/Label2DRenderer.js +++ b/src/Renderer/Label2DRenderer.js @@ -69,12 +69,16 @@ class ScreenGrid { const miny = Math.max(0, Math.floor(obj.boundaries.top / this.height * this.y)); const maxy = Math.min(this.y - 1, Math.floor(obj.boundaries.bottom / this.height * this.y)); - for (let i = minx; i <= maxx; i++) { - for (let j = miny; j <= maxy; j++) { - if (this.grid[i][j].length > 0) { - if (this.grid[i][j].some(l => isIntersectedOrOverlaped(l.boundaries, obj.boundaries))) { - this.hidden.push(obj); - return false; + if (!obj.allowOverlapping) { + for (let i = minx; i <= maxx; i++) { + for (let j = miny; j <= maxy; j++) { + if (this.grid[i][j].length > 0) { + if (this.grid[i][j].some( + l => !l.allowOverlapping && isIntersectedOrOverlaped(l.boundaries, obj.boundaries)) + ) { + this.hidden.push(obj); + return false; + } } } } diff --git a/test/unit/label.js b/test/unit/label.js index b7f5488d61..14de970589 100644 --- a/test/unit/label.js +++ b/test/unit/label.js @@ -38,6 +38,16 @@ describe('LabelLayer', function () { const labels = layer.convert(collection, extent); assert.ok(labels[0].isLabel); }); + + it('should replace style.point by style.icon if displayAsIcon is true', function () { + const labelLayer = new LabelLayer('labelLayer', { + source: false, + style: { point: { color: 'red', line: 'green', displayAsIcon: true } }, + }); + labelLayer.source = {}; + assert.strictEqual(labelLayer.style.point.color, undefined); + assert.strictEqual(labelLayer.style.point.line, undefined); + }); }); describe('Label', function () {