UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

471 lines (438 loc) 16.7 kB
import * as THREE from 'three'; import LayerUpdateState from "./LayerUpdateState.js"; import ObjectRemovalHelper from "../Process/ObjectRemovalHelper.js"; import GeometryLayer from "./GeometryLayer.js"; import { Coordinates, Extent } from '@itowns/geographic'; import Label from "../Core/Label.js"; import Style, { readExpression, StyleContext } from "../Core/Style.js"; import { ScreenGrid } from "../Renderer/Label2DRenderer.js"; const context = new StyleContext(); const coord = new Coordinates('EPSG:4326', 0, 0, 0); const _extent = new Extent('EPSG:4326', 0, 0, 0, 0); const nodeDimensions = new THREE.Vector2(); const westNorthNode = new THREE.Vector2(); const labelPosition = new THREE.Vector2(); /** * DomNode is a node in the tree data structure of labels divs. * * @class DomNode */ class DomNode { #domVisibility = false; constructor() { this.dom = document.createElement('div'); this.dom.style.display = 'none'; this.visible = true; } get visible() { return this.#domVisibility; } set visible(v) { if (v !== this.#domVisibility) { this.#domVisibility = v; this.dom.style.display = v ? 'block' : 'none'; } } hide() { this.visible = false; } show() { this.visible = true; } add(node) { this.dom.append(node.dom); } } /** * LabelsNode is node of tree data structure for LabelLayer. * the node is made of dom elements and 3D labels. * * @class LabelsNode */ class LabelsNode extends THREE.Group { constructor(node) { super(); // attached node parent this.nodeParent = node; // When this is set, it calculates the position in that frame and resets this property to false. this.needsUpdate = true; } // instanciate dom elements initializeDom() { // create root dom this.domElements = new DomNode(); // create labels container dom this.domElements.labels = new DomNode(); this.domElements.add(this.domElements.labels); this.domElements.labels.dom.style.opacity = '0'; } // add node label // add label 3d and dom label addLabel(label) { // add 3d object this.add(label); // add dom label this.domElements.labels.dom.append(label.content); // Batch update the dimensions of labels all at once to avoid // redraw for at least this tile. label.initDimensions(); // add horizon culling point if it's necessary // the horizon culling is applied to nodes that trace the horizon which // corresponds to the low zoom node, that's why the culling is done for a zoom lower than 4. if (this.nodeParent.layer.isGlobeLayer && this.nodeParent.level < 4) { label.horizonCullingPoint = new THREE.Vector3(); } } // remove node label // remove label 3d and dom label removeLabel(label) { // remove 3d object this.remove(label); // remove dom label this.domElements.labels.dom.removeChild(label.content); } // update position if it's necessary updatePosition(label) { if (this.needsUpdate) { // update elevation from elevation layer. if (this.needsAltitude) { label.updateElevationFromLayer(this.nodeParent.layer, [this.nodeParent]); } // update elevation label label.update3dPosition(this.nodeParent.layer.crs); // update horizon culling label.updateHorizonCullingPoint(); } } // return labels count count() { return this.children.length; } get labels() { return this.children; } } /** * 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 * it work with it (see the `vector_tile_raster_2d` example). Supported for Points features, not yet * for Lines and Polygons features. * * @property {boolean} isLabelLayer - Used to checkout whether this layer is a * LabelLayer. Default is true. You should not change this, as it is used * internally for optimisation. */ class LabelLayer extends GeometryLayer { #filterGrid = (() => new ScreenGrid())(); /** * @extends Layer * * @param {string} id - The id of the layer, that should be unique. It is * not mandatory, but an error will be emitted if this layer is added a * {@link View} that already has a layer going by that id. * @param {Object} [config] - Optional configuration, all elements in it * will be merged as is in the layer. For example, if the configuration * contains three elements `name, protocol, extent`, these elements will be * available using `layer.name` or something else depending on the property * name. * @param {boolean} [config.performance=true] - remove labels that have no chance of being visible. * if the `config.performance` is set to true then the performance is improved * proportional to the amount of unnecessary labels that are removed. * Indeed, even in the best case, labels will never be displayed. By example, if there's many labels. * We advise you to not use this option if your data is optimized. * @param {domElement|function} config.domElement - An HTML domElement. * If set, all `Label` displayed within the current instance `LabelLayer` * will be this domElement. * * It can be set to a method. The single parameter of this method gives the * properties of each feature on which a `Label` is created. * * If set, all the parameters set in the `LabelLayer` `Style.text` will be overridden, * except for the `Style.text.anchor` parameter which can help place the label. */ constructor(id) { let config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; const { domElement, performance = true, forceClampToTerrain = false, margin, style = {}, ...geometryConfig } = config; super(id, config.object3d || new THREE.Group(), geometryConfig); this.isLabelLayer = true; this.style = style instanceof Style ? style : new Style(style); this.domElement = new DomNode(); this.domElement.show(); this.domElement.dom.id = `itowns-label-${this.id}`; this.buildExtent = true; this.crs = config.source.crs; this.performance = performance; this.forceClampToTerrain = forceClampToTerrain; this.margin = margin; this.toHide = new THREE.Group(); this.labelDomelement = 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; } get visible() { return super.visible; } set visible(value) { super.visible = value; if (value) { this.domElement?.show(); } else { this.domElement?.hide(); } } get submittedLabelNodes() { return this.object3d.children; } /** * Reads each {@link FeatureGeometry} that contains label configuration, and * creates the corresponding {@link Label}. To create a `Label`, a geometry * needs to have a `label` object with at least a few properties: * - `content`, which refers to `Label#content` * - `position`, which refers to `Label#position` * - (optional) `config`, containing miscellaneous configuration for the * label * * The geometry (or its parent Feature) needs to have a Style set. * * @param {FeatureCollection} data - The FeatureCollection to read the * labels from. * @param {Extent|Tile} extentOrTile * * @return {Label[]} An array containing all the created labels. */ convert(data, extentOrTile) { const labels = []; // Converting the extent now is faster for further operation if (extentOrTile.isExtent) { extentOrTile.as(data.crs, _extent); } else { extentOrTile.toExtent(data.crs, _extent); } coord.crs = data.crs; context.setZoom(extentOrTile.zoom); data.features.forEach(f => { if (f.style.text) { if (Object.keys(f.style.text).length === 0) { return; } } context.setFeature(f); const featureField = f.style?.text?.field; // determine if altitude style is specified by the user const altitudeStyle = f.style?.point?.base_altitude; const isDefaultElevationStyle = altitudeStyle instanceof Function && altitudeStyle.name == 'baseAltitudeDefault'; // determine if the altitude needs update with ElevationLayer labels.needsAltitude = labels.needsAltitude || this.forceClampToTerrain === true || isDefaultElevationStyle && !f.hasRawElevationData; f.geometries.forEach(g => { context.setGeometry(g); this.style.setContext(context); const layerField = this.style.text && this.style.text.field; const geometryField = g.properties.style && g.properties.style.text && g.properties.style.text.field; let content; 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 && (f.style.icon.source || f.style.icon.key)) && !(this.style.icon && (this.style.icon.source || this.style.icon.key))) { return; } } if (this.style.zoom.min > this.style.context.zoom || this.style.zoom.max <= this.style.context.zoom) { return; } // NOTE: this only works fine for POINT. // It needs more work for LINE and POLYGON as we currently only use the first point of the entity g.indices.forEach(i => { coord.setFromArray(f.vertices, g.size * i.offset); // Transform coordinate to data.crs projection coord.applyMatrix4(data.matrixWorld); if (!_extent.isPointInside(coord)) { return; } const label = new Label(content, coord.clone(), this.style); label.layerId = this.id; label.order = f.order; label.padding = this.margin || label.padding; labels.push(label); }); }); }); return labels; } // placeholder preUpdate(context, sources) { if (sources.has(this.parent)) { this.object3d.clear(); this.#filterGrid.width = this.parent.maxScreenSizeNode * 0.5; this.#filterGrid.height = this.parent.maxScreenSizeNode * 0.5; this.#filterGrid.resize(); } } #submitToRendering(labelsNode) { this.object3d.add(labelsNode); } #disallowToRendering(labelsNode) { this.toHide.add(labelsNode); } #findClosestDomElement(node) { if (node.parent?.isTileMesh) { return node.parent.link[this.id]?.domElements || this.#findClosestDomElement(node.parent); } else { return this.domElement; } } #hasLabelChildren(object) { return object.children.every(c => c.layerUpdateState && c.layerUpdateState[this.id]?.hasFinished()); } // Remove all labels invisible with pre-culling with screen grid // We use the screen grid with maximum size of node on screen #removeCulledLabels(node) { // copy labels array const labels = node.children.slice(); // reset filter this.#filterGrid.reset(); // sort labels by order labels.sort((a, b) => b.order - a.order); labels.forEach(label => { // get node dimensions node.nodeParent.extent.planarDimensions(nodeDimensions); coord.crs = node.nodeParent.extent.crs; // get west/north node coordinates coord.setFromValues(node.nodeParent.extent.west, node.nodeParent.extent.north, 0).toVector3(westNorthNode); // get label position coord.copy(label.coordinates).as(node.nodeParent.extent.crs, coord).toVector3(labelPosition); // transform label position to local node system labelPosition.sub(westNorthNode); labelPosition.y += nodeDimensions.y; labelPosition.divide(nodeDimensions).multiplyScalar(this.#filterGrid.width); // update the projected position to transform to local filter grid sytem label.updateProjectedPosition(labelPosition.x, labelPosition.y); // use screen grid to remove all culled labels if (!this.#filterGrid.insert(label)) { node.removeLabel(label); } }); } update(context, layer, node, parent) { if (!parent && node.link[layer.id]) { // if node has been removed dispose three.js resource ObjectRemovalHelper.removeChildrenAndCleanupRecursively(this, node); return; } const labelsNode = node.link[layer.id] || new LabelsNode(node); node.link[layer.id] = labelsNode; if (this.frozen || !node.visible || !this.visible) { return; } if (!node.material.visible && this.#hasLabelChildren(node)) { return this.#disallowToRendering(labelsNode); } const extentsDestination = node.getExtentsByProjection(this.source.crs) || [node.extent]; const zoomDest = extentsDestination[0].zoom; if (zoomDest < layer.zoom.min || zoomDest > layer.zoom.max) { return this.#disallowToRendering(labelsNode); } if (node.layerUpdateState[this.id] === undefined) { node.layerUpdateState[this.id] = new LayerUpdateState(); } if (!this.source.extentInsideLimit(node.extent, zoomDest)) { node.layerUpdateState[this.id].noMoreUpdatePossible(); return; } else if (this.#hasLabelChildren(node.parent)) { if (!node.material.visible) { labelsNode.needsUpdate = true; } this.#submitToRendering(labelsNode); return; } else if (!node.layerUpdateState[this.id].canTryUpdate()) { return; } node.layerUpdateState[this.id].newTry(); const command = { layer: this, extentsSource: extentsDestination, view: context.view, requester: node }; return context.scheduler.execute(command).then(result => { if (!result) { return; } const renderer = context.view.mainLoop.gfxEngine.label2dRenderer; labelsNode.initializeDom(); this.#findClosestDomElement(node).add(labelsNode.domElements); result.forEach(labels => { // Clean if there isnt' parent if (!node.parent) { labels.forEach(l => { ObjectRemovalHelper.removeChildrenAndCleanupRecursively(this, l); renderer.removeLabelDOM(l); }); return; } labelsNode.needsAltitude = labelsNode.needsAltitude || labels.needsAltitude; // Add all labels for this tile at once to batch it labels.forEach(label => { if (node.extent.isPointInside(label.coordinates)) { labelsNode.addLabel(label); } }); }); if (labelsNode.count()) { labelsNode.domElements.labels.hide(); labelsNode.domElements.labels.dom.style.opacity = '1.0'; node.addEventListener('show', () => labelsNode.domElements.labels.show()); node.addEventListener('hidden', () => this.#disallowToRendering(labelsNode)); // Necessary event listener, to remove any Label attached to node.addEventListener('removed', () => this.removeNodeDomElement(node)); if (labelsNode.needsAltitude && node.material.getElevationTile()) { node.material.getElevationTile().addEventListener('rasterElevationLevelChanged', () => { labelsNode.needsUpdate = true; }); } if (this.performance) { this.#removeCulledLabels(labelsNode); } } node.layerUpdateState[this.id].noMoreUpdatePossible(); }); } removeLabelsFromNodeRecursive(node) { node.children.forEach(c => { if (c.link[this.id]) { delete c.link[this.id]; } this.removeLabelsFromNodeRecursive(c); }); this.removeNodeDomElement(node); } removeNodeDomElement(node) { if (node.link[this.id]?.domElements) { const child = node.link[this.id].domElements.dom; child.parentElement.removeChild(child); delete node.link[this.id].domElements; } } /** * All layer's objects and domElements are removed. * @param {boolean} [clearCache=false] Whether to clear the layer cache or not */ delete(clearCache) { if (clearCache) { this.cache.clear(); } this.domElement.dom.parentElement.removeChild(this.domElement.dom); this.parent.level0Nodes.forEach(obj => this.removeLabelsFromNodeRecursive(obj)); } } export default LabelLayer;