diff --git a/components/map3d/HeightProfile3D.jsx b/components/map3d/HeightProfile3D.jsx new file mode 100644 index 000000000..4a1c5be2b --- /dev/null +++ b/components/map3d/HeightProfile3D.jsx @@ -0,0 +1,328 @@ +/** + * Copyright 2017-2024 Sourcepole AG + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import {Line} from "react-chartjs-2"; +import ReactDOM from 'react-dom'; + +import Shape from '@giro3d/giro3d/entities/Shape.js'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Filler +} from 'chart.js'; +import FileSaver from 'file-saver'; +import PropTypes from 'prop-types'; +import {Vector3} from 'three'; + +import LocaleUtils from '../../utils/LocaleUtils'; +import MeasureUtils from '../../utils/MeasureUtils'; +import MiscUtils from '../../utils/MiscUtils'; +import ResizeableWindow from '../ResizeableWindow'; + +import '../../plugins/style/HeightProfile.css'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Filler +); + +class HeightProfilePrintDialog extends React.PureComponent { + static propTypes = { + children: PropTypes.func, + onClose: PropTypes.func, + sceneContext: PropTypes.object, + templatePath: PropTypes.string + }; + constructor(props) { + super(props); + this.externalWindow = null; + this.chart = null; + this.portalEl = null; + this.imageEl = null; + } + state = { + initialized: false, + imageUrl: '' + }; + componentDidMount() { + const templatePath = MiscUtils.resolveAssetsPath(this.props.templatePath); + this.externalWindow = window.open(templatePath, LocaleUtils.tr("heightprofile.title"), "toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=yes"); + this.externalWindow.addEventListener('load', this.setWindowContent, false); + this.externalWindow.addEventListener('resize', this.windowResized, false); + window.addEventListener('beforeunload', this.closePrintWindow); + this.props.sceneContext.scene.view.controls.addEventListener('change', this.scheduleRefreshImage); + } + componentDidUpdate(prevProps, prevState) { + if ((this.state.initialized && !prevState.initialized)) { + this.refreshImage(); + } + } + componentWillUnmount() { + this.closePrintWindow(); + window.removeEventListener('beforeunload', this.closePrintWindow); + this.props.sceneContext.scene.view.controls.removeEventListener('change', this.scheduleRefreshImage); + } + closePrintWindow = () => { + this.externalWindow.close(); + }; + setWindowContent = () => { + this.externalWindow.addEventListener('beforeunload', this.props.onClose, false); + const container = this.externalWindow.document.getElementById("heightprofilecontainer"); + if (container) { + const printBtn = this.externalWindow.document.createElement('div'); + printBtn.id = "print"; + printBtn.style.marginBottom = "1em"; + printBtn.innerHTML = '' + + ''; + container.appendChild(printBtn); + + this.imageEl = this.externalWindow.document.createElement('div'); + this.imageEl.innerHTML = LocaleUtils.tr("heightprofile.loadingimage"); + container.appendChild(this.imageEl); + + this.portalEl = this.externalWindow.document.createElement('div'); + this.portalEl.id = 'profile'; + container.appendChild(this.portalEl); + + this.setState({initialized: true}); + this.externalWindow.document.body.style.overflowX = 'hidden'; + } else { + this.externalWindow.document.body.innerHTML = "Broken template. An element with id=heightprofilecontainer must exist."; + } + }; + scheduleRefreshImage = () => { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = setTimeout(this.refreshImage, 500); + }; + refreshImage = () => { + const src = this.props.sceneContext.scene.renderer.domElement.toDataURL('image/png'); + const width = this.props.sceneContext.scene.renderer.domElement.offsetWidth; + this.imageEl.innerHTML = ``; + }; + windowResized = () => { + if (this.chart) { + this.chart.resize(); + } + }; + render() { + if (!this.state.initialized) { + return null; + } + return ReactDOM.createPortal(this.props.children(el => {this.chart = el;}, false), this.portalEl); + } +} + + +export default class HeightProfile extends React.Component { + static propTypes = { + data: PropTypes.array, + /** The height of the height profile widget in pixels. */ + height: PropTypes.number, + /** The precision of displayed and exported values (0: no decimals, 1: 1 decimal position, etc). */ + heightProfilePrecision: PropTypes.number, + sceneContext: PropTypes.object, + /** Template location for the height profile print functionality */ + templatePath: PropTypes.string + }; + static defaultProps = { + heightProfilePrecision: 0, + height: 150, + templatePath: ":/templates/heightprofileprint.html" + }; + state = { + printdialog: false, + visible: true + }; + constructor(props) { + super(props); + this.chart = null; + this.profilePrintWindow = null; + } + componentDidMount() { + this.marker = new Shape({ + showVertexLabels: true, + showLine: false, + showVertices: true, + vertexLabelFormatter: ({position}) => MeasureUtils.formatMeasurement(position.z, false, 'm') + }); + this.marker.visible = false; + this.props.sceneContext.scene.add(this.marker); + } + componentWillUnmount() { + this.props.sceneContext.scene.remove(this.marker); + } + componentDidUpdate(prevProps) { + if (this.props.data !== prevProps.data) { + this.setState({visible: true}); + } + } + onClose = () => { + this.setState({visible: false, printdialog: false}); + this.marker.visible = false; + }; + render() { + if (!this.state.visible) { + return null; + } + const extraControls = [ + {icon: 'export', callback: this.exportProfile, title: LocaleUtils.tr("heightprofile.export")}, + {icon: 'print', active: this.state.printdialog, callback: () => this.setState(state => ({printdialog: !state.printdialog})), title: LocaleUtils.tr("heightprofile.print")} + ]; + return [( + + {this.renderHeightProfile((el) => { this.chart = el; }, true)} + + ), + this.state.printdialog ? ( + this.setState({printdialog: false})} sceneContext={this.props.sceneContext} templatePath={this.props.templatePath}> + {this.renderHeightProfile} + + ) : null]; + } + renderHeightProfile = (saveRef, interactive) => { + const distanceStr = LocaleUtils.tr("heightprofile.distance"); + const heightStr = LocaleUtils.tr("heightprofile.height"); + const aslStr = LocaleUtils.tr("heightprofile.asl"); + + const data = { + labels: this.props.data.map(entry => entry[3]), + datasets: [ + { + data: this.props.data.map(entry => entry[2]), + fill: true, + backgroundColor: "rgba(255,0,0,0.5)", + borderColor: "rgb(255,0,0)", + borderWidth: 2, + pointRadius: 0, + order: 1 + } + ] + }; + // Approx 10 ticks + const totLength = this.props.data[this.props.data.length - 1][3]; + const maxHeight = Math.max(...this.props.data.map(x => x[2])); + const stepSizeFact = Math.pow(10, Math.ceil(Math.log10(totLength / 10))); + const stepSize = Math.round(totLength / (stepSizeFact)) * stepSizeFact / 10; + const prec = this.props.heightProfilePrecision; + const options = { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 0 + }, + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: interactive, + intersect: false, + displayColors: false, + bodyFont: {weight: 'bold'}, + callbacks: { + title: (ctx) => (distanceStr + ": " + MeasureUtils.formatMeasurement(ctx[0].parsed.x, false, 'metric')), + label: (ctx) => (heightStr + ": " + MeasureUtils.formatMeasurement(ctx.parsed.y, false, 'm') + " " + aslStr) + } + } + }, + scales: { + x: { + type: 'linear', + ticks: { + stepSize: stepSize, + font: {size: 10}, + callback: (value) => value + }, + title: { + display: true, + text: distanceStr + " [m]", + padding: 0 + }, + max: Math.ceil(totLength) + }, + y: { + ticks: { + font: {size: 10}, + callback: (value) => value.toFixed(prec) + }, + title: { + display: true, + text: heightStr + " [m " + aslStr + "]" + }, + max: Math.ceil(maxHeight) + } + }, + onHover: interactive ? (evt, activeEls, chart) => { + const chartArea = chart.chartArea; + const chartX = Math.min(Math.max(evt.x - chartArea.left), chartArea.width); + this.updateMarker(chartX / chartArea.width * totLength); + } : undefined + }; + + return ( +
+ +
+ ); + }; + resizeChart = () => { + if (this.chart) { + this.chart.resize(); + } + }; + updateMarker = (dist) => { + const data = this.props.data; + const i = data.findIndex(x => x[3] >= dist); + if (i === 0) { + this.marker.setPoints([new Vector3(...data[0])]); + } else { + const lambda = (dist - data[i - 1][3]) / (data[i][3] - data[i - 1][3]); + const p = new Vector3( + data[i - 1][0] + lambda * (data[i][0] - data[i][0]), + data[i - 1][1] + lambda * (data[i][1] - data[i][1]), + data[i - 1][2] + lambda * (data[i][2] - data[i][2]) + ); + this.marker.setPoints([p]); + } + this.marker.visible = true; + }; + hideMarker = () => { + this.marker.visible = false; + }; + exportProfile = () => { + let csv = ""; + csv += "index" + "\t" + "distance" + "\t" + "elevation" + "\n"; + this.props.data.forEach((entry, idx) => { + const sample = {x: entry[3], y: entry[2]}; + const prec = this.props.heightProfilePrecision; + const distance = Math.round(sample.x * Math.pow(10, prec)) / Math.pow(10, prec); + const height = Math.round(sample.y * Math.pow(10, prec)) / Math.pow(10, prec); + csv += String(idx).replace('"', '""') + "\t" + + String(distance) + "\t" + + String(height) + "\n"; + }); + FileSaver.saveAs(new Blob([csv], {type: "text/plain;charset=utf-8"}), "heightprofile.csv"); + }; +} diff --git a/components/map3d/Map3D.jsx b/components/map3d/Map3D.jsx index daa49044b..e49145f33 100644 --- a/components/map3d/Map3D.jsx +++ b/components/map3d/Map3D.jsx @@ -32,6 +32,7 @@ import Icon from '../Icon'; import BottomBar3D from './BottomBar3D'; import LayerTree3D from './LayerTree3D'; import Map3DLight from './Map3DLight'; +import Measure3D from './Measure3D'; import OverviewMap3D from './OverviewMap3D'; import TopBar3D from './TopBar3D'; import LayerRegistry from './layers/index'; @@ -337,6 +338,7 @@ class Map3D extends React.Component { + ) : null} @@ -360,7 +362,8 @@ class Map3D extends React.Component { target: this.container, crs: projection, renderer: { - clearColor: 0x000000 + clearColor: 0x000000, + preserveDrawingBuffer: true } }); diff --git a/components/map3d/Measure3D.jsx b/components/map3d/Measure3D.jsx new file mode 100644 index 000000000..2c83e6676 --- /dev/null +++ b/components/map3d/Measure3D.jsx @@ -0,0 +1,383 @@ +/** + * Copyright 2024 Sourcepole AG + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +import Coordinates from '@giro3d/giro3d/core/geographic/Coordinates'; +import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer'; +import Shape from '@giro3d/giro3d/entities/Shape'; +import DrawTool, {conditions} from "@giro3d/giro3d/interactions/DrawTool.js"; +import VectorSource from '@giro3d/giro3d/sources/VectorSource'; +import ol from 'openlayers'; +import pointInPolygon from 'point-in-polygon'; +import PropTypes from 'prop-types'; +import {CurvePath, LineCurve, Vector2, Vector3} from 'three'; + +import ConfigUtils from '../../utils/ConfigUtils'; +import CoordinatesUtils from '../../utils/CoordinatesUtils'; +import LocaleUtils from '../../utils/LocaleUtils'; +import MeasureUtils from '../../utils/MeasureUtils'; +import TaskBar from '../TaskBar'; +import ButtonBar from '../widgets/ButtonBar'; +import CopyButton from '../widgets/CopyButton'; +import HeightProfile3D from './HeightProfile3D'; + +import '../../plugins/style/Measure.css'; + + +export default class Measure3D extends React.Component { + static propTypes = { + maxSampleCount: PropTypes.number, + minMeasureLength: PropTypes.number, + sceneContext: PropTypes.object + }; + static defaultProps = { + maxSampleCount: 500, + minMeasureLength: 5 + }; + state = { + mode: null, + result: null, + lenUnit: 'metric', + areaUnit: 'metric', + elevUnit: 'absolute' + }; + constructor(props) { + super(props); + this.measureTool = null; + this.measurementObjects = []; + } + componentDidUpdate(prevProps, prevState) { + if (this.state.mode !== prevState.mode) { + this.clearResult(); + this.restart(); + } + } + onShow = (mode) => { + this.setState({mode: mode ?? 'Point'}); + this.abortController = new AbortController(); + this.measureTool = new DrawTool({ + instance: this.props.sceneContext.scene + }); + this.drawLayer = new ColorLayer({ + source: new VectorSource({ + data: [], + format: new ol.format.GeoJSON(), + style: this.featureStyleFunction + }) + }); + this.props.sceneContext.map.addLayer(this.drawLayer); + }; + onHide = () => { + this.clearResult(); + this.setState({mode: null}); + this.abortController.abort(); + this.abortController = null; + this.measureTool.dispose(); + this.measureTool = null; + this.props.sceneContext.map.removeLayer(this.drawLayer, {dispose: true}); + }; + renderModeSwitcher = () => { + const buttons = [ + {key: "Point", label: LocaleUtils.tr("measureComponent.pointLabel")}, + {key: "LineString", label: LocaleUtils.tr("measureComponent.lengthLabel")}, + {key: "Polygon", label: LocaleUtils.tr("measureComponent.areaLabel")} + ]; + return ( + this.setState({mode, result: null})} /> + ); + }; + renderResult = () => { + if (!this.state.result) { + return null; + } + let text = ""; + let unitSelector = null; + + if (this.state.mode === "Point") { + text = CoordinatesUtils.getFormattedCoordinate(this.state.result.pos.slice(0, 2), this.props.sceneContext.mapCrs); + if (this.state.result.ground >= 0) { + const prec = ConfigUtils.getConfigProp("measurementPrecision"); + text += ", " + (this.state.elevUnit === 'ground' ? this.state.result.ground : this.state.result.pos[2]).toFixed(prec); + unitSelector = ( + + ); + } + } else if (this.state.mode === "LineString") { + text = MeasureUtils.formatMeasurement(this.state.result.length, false, this.state.lenUnit); + unitSelector = ( + + ); + } else if (this.state.mode === "Polygon") { + text = MeasureUtils.formatMeasurement(this.state.result, true, this.state.areaUnit); + unitSelector = ( + + ); + } + return ( +
+ + {unitSelector} + +
+ ); + }; + render() { + return [ + ( + + {() => ({ + body: ( +
+ {this.renderModeSwitcher()} + {this.renderResult()} +
+ ) + })} +
+ ), + this.state.result?.profile ? ( + + ) : null + ]; + } + featureStyleFunction = () => { + return [ + new ol.style.Style({ + fill: new ol.style.Fill({ + color: [41, 120, 180, 0.5] + }) + }), + new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: [255, 255, 255], + width: 4 + }) + }), + new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: [41, 120, 180], + width: 1.5 + }) + }) + ]; + }; + clearResult = () => { + this.drawLayer.source.clear(); + this.measurementObjects.forEach(object => { + this.props.sceneContext.scene.remove(object); + }); + this.setState({result: null}); + }; + restart = () => { + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + const options = { + signal: this.abortController.signal, + endCondition: conditions.doubleClick + }; + if (this.state.mode === 'Point') { + this.measureTool.createPoint(options).then(this.measurePoint); + } else if (this.state.mode === 'LineString') { + this.measureTool.createLineString(options).then(this.measureLine); + } else if (this.state.mode === 'Polygon') { + this.measureTool.createPolygon(options).then(this.measureArea); + } else if (this.state.mode === 'Height') { + this.measureTool.createVerticalMeasure(options).then(this.measureHeight); + } + }; + measurePoint = (point) => { + if (point === null) { + this.restart(); + return; + } + this.clearResult(); + const pos = point.points[0]; + + // Measure point above terrain + const elevation = this.getElevation([pos.x, pos.y]); + if (pos.z - elevation > 0) { + const elevationStr = MeasureUtils.formatMeasurement(pos.z - elevation, false, 'm'); + // Add line + const line = new Shape({ + showLineLabel: true, + showLine: true, + showVertices: true, + lineLabelFormatter: () => elevationStr + }); + line.setPoints([new Vector3(pos.x, pos.y, elevation), pos]); + this.props.sceneContext.scene.add(line); + this.measurementObjects.push(line); + this.props.sceneContext.scene.remove(point); + } else { + this.measurementObjects.push(point); + } + + this.setState({result: {pos: [pos.x, pos.y, pos.z], ground: pos.z - elevation}}); + + // Setup for next measurement + this.restart(); + }; + measureLine = (lineString) => { + if (lineString === null) { + this.restart(); + return; + } + this.clearResult(); + const features = (new ol.format.GeoJSON()).readFeatures(lineString.toGeoJSON(), { + dataProjection: "EPSG:4326", + featureProjection: this.props.sceneContext.mapCrs + }); + this.drawLayer.source.addFeatures(features); + this.props.sceneContext.scene.remove(lineString); + + // Compute 2d length and nSamples spaced points + const path = new CurvePath(); + let len2d = 0; + for (let i = 0; i < lineString.points.length - 1; i++) { + const v0 = lineString.points[i]; + const v1 = lineString.points[i + 1]; + + const line = new LineCurve( + new Vector2(v0.x, v0.y), + new Vector2(v1.x, v1.y), + ); + path.add(line); + len2d += Math.sqrt((v1.x - v0.x) * (v1.x - v0.x) + (v1.y - v0.y) * (v1.y - v0.y)); + } + const nSamples = Math.min(this.props.maxSampleCount, Math.round(len2d / this.props.minMeasureLength)); + + const points = path.getSpacedPoints(nSamples - 1); + const line3d = new Array(nSamples); + line3d[0] = [points[0].x, points[0].y, this.getElevation([points[0].x, points[0].y]), 0]; + let len3d = 0; + for (let i = 1; i < nSamples; ++i) { + line3d[i] = [points[i].x, points[i].y, this.getElevation([points[i].x, points[i].y]), 0]; + const dx = line3d[i][0] - line3d[i - 1][0]; + const dy = line3d[i][1] - line3d[i - 1][1]; + const dz = line3d[i][2] - line3d[i - 1][2]; + len3d += Math.sqrt(dx * dx + dy * dy + dz * dz); + line3d[i][3] = len3d; // Also store incremental length for height profie + } + this.setState({result: {length: len3d, profile: line3d}}); + + // Setup for next measurement + this.restart(); + }; + measureArea = (polygon) => { + if (polygon === null) { + this.restart(); + return; + } + this.clearResult(); + + const features = (new ol.format.GeoJSON()).readFeatures(polygon.toGeoJSON(), { + dataProjection: "EPSG:4326", + featureProjection: this.props.sceneContext.mapCrs + }); + this.drawLayer.source.addFeatures(features); + this.props.sceneContext.scene.remove(polygon); + + // Compute boundingbox of polygon, divide boundingbox into quads, + // compute quad area on terrain for each quad in polygon + const bbox = [ + polygon.points[0].x, polygon.points[0].y, + polygon.points[0].x, polygon.points[0].y + ]; + const coordinates = polygon.points.map(v => { + bbox[0] = Math.min(bbox[0], v.x); + bbox[1] = Math.min(bbox[1], v.y); + bbox[2] = Math.max(bbox[2], v.x); + bbox[3] = Math.max(bbox[3], v.y); + return [v.x, v.y]; + }); + const quadSize = this.props.minMeasureLength; + const numX = Math.min(this.props.maxSampleCount, Math.round((bbox[2] - bbox[0]) / quadSize)); + const numY = Math.min(this.props.maxSampleCount, Math.round((bbox[3] - bbox[1]) / quadSize)); + const deltaX = (bbox[2] - bbox[0]) / numX; + const deltaY = (bbox[3] - bbox[1]) / numY; + let area = 0; + const elevationCache = new Array(numX * numY); + for (let iX = 0; iX < numX - 1; ++iX) { + for (let iY = 0; iY < numY - 1; ++iY) { + // If quad center lies in polygon, consider it + const p = [bbox[0] + iX * deltaX, bbox[1] + iY * deltaY]; + const c = [p[0] + 0.5 * deltaX, p[1] + 0.5 * deltaY]; + if (!pointInPolygon(c, coordinates)) { + continue; + } + // Get elevations + const z1 = elevationCache[iY * numX + iX] ?? (elevationCache[iY * numX + iX] = this.getElevation(p)); + const z2 = elevationCache[iY * numX + iX + 1] ?? (elevationCache[iY * numX + iX + 1] = this.getElevation([p[0] + deltaX, p[1]])); + const z3 = elevationCache[(iY + 1) * numX + iX + 1] ?? (elevationCache[(iY + 1) * numX + iX + 1] = this.getElevation([p[0] + deltaX, p[1] + deltaY])); + const z4 = elevationCache[(iY + 1) * numX + iX] ?? (elevationCache[(iY + 1) * numX + iX] = this.getElevation([p[0], p[1] + deltaY])); + // Divide quad along diagonal with smaller elevation difference + const dz1 = Math.abs(z3 - z1); + const dz2 = Math.abs(z4 - z2); + if (dz1 < dz2) { + const area1 = this.triangleArea([-deltaX, 0, z1 - z2], [0, deltaY, z3 - z2]); + const area2 = this.triangleArea([0, -deltaY, z1 - z4], [deltaX, 0, z3 - z4]); + area += area1 + area2; + } else { + const area1 = this.triangleArea([deltaX, 0, z2 - z1], [0, deltaY, z4 - z1]); + const area2 = this.triangleArea([-deltaX, 0, z4 - z3], [0, -deltaY, z1 - z3]); + area += area1 + area2; + } + } + } + this.setState({result: area}); + + // Setup for next measurement + this.restart(); + }; + measureHeight = (lineString) => { + if (lineString === null) { + this.restart(); + return; + } + this.clearResult(); + this.measurementObjects.push(lineString); + + // Setup for next measurement + this.restart(); + }; + getElevation = (point) => { + const coordinates = new Coordinates(this.props.sceneContext.mapCrs, point[0], point[1], 0); + const result = this.props.sceneContext.map.getElevation({coordinates}); + if (result.samples.length > 0) { + result.samples.sort((a, b) => a.resolution - b.resolution); + return result.samples[0].elevation; + } + return null; + }; + triangleArea = (u, v) => { + const cross = [u[1] * v[2] - u[2] * v[1], u[0] * v[2] - u[2] * v[0], u[0] * v[1] - u[1] * v[0]]; + return 0.5 * Math.sqrt(cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]); + }; +} diff --git a/package.json b/package.json index 1f77ef841..10cd4dcdc 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "qwc2", - "version": "2024.12.20-master", + "version": "2025.01.02-master", "description": "QGIS Web Client 2 core", "author": "Sourcepole AG", "license": "BSD-2-Clause", "repository": "git@github.com:qgis/qwc2.git", "dependencies": { - "@giro3d/giro3d": "^0.40.0", + "@giro3d/giro3d": "^0.41.0", "@ladjs/country-language": "^1.0.3", "@loaders.gl/core": "^4.3.3", "@loaders.gl/shapefile": "^4.3.3",