diff --git a/package-lock.json b/package-lock.json index b4cbffc67..2b30d38e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@geoblocks/ol-maplibre-layer": "^0.1.2", "@ivanv/vue-collapse-transition": "^1.0.2", "@popperjs/core": "^2.11.8", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", "animate.css": "^4.1.1", "axios": "^1.6.2", "bootstrap": "^5.3.2", @@ -1000,6 +1002,37 @@ "node": ">= 10" } }, + "node_modules/@turf/distance": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz", + "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tweenjs/tween.js": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-21.0.0.tgz", diff --git a/package.json b/package.json index dcf595048..6664e342b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "@geoblocks/ol-maplibre-layer": "^0.1.2", "@ivanv/vue-collapse-transition": "^1.0.2", "@popperjs/core": "^2.11.8", + "@turf/distance": "^6.5.0", + "@turf/helpers": "^6.5.0", "animate.css": "^4.1.1", "axios": "^1.6.2", "bootstrap": "^5.3.2", diff --git a/src/modules/infobox/components/FeatureList.vue b/src/modules/infobox/components/FeatureList.vue index 4410e5a34..4b80be917 100644 --- a/src/modules/infobox/components/FeatureList.vue +++ b/src/modules/infobox/components/FeatureList.vue @@ -45,7 +45,7 @@ export default { } - diff --git a/src/modules/map/components/cesium/CesiumMap.vue b/src/modules/map/components/cesium/CesiumMap.vue index c9c732fae..9f35f94a0 100644 --- a/src/modules/map/components/cesium/CesiumMap.vue +++ b/src/modules/map/components/cesium/CesiumMap.vue @@ -91,7 +91,7 @@ import { ClickInfo, ClickType } from '@/store/modules/map.store' import { UIModes } from '@/store/modules/ui.store' import { WEBMERCATOR, WGS84 } from '@/utils/coordinates/coordinateSystems' import CustomCoordinateSystem from '@/utils/coordinates/CustomCoordinateSystem.class' -import { createGeoJSONFeature } from '@/utils/layerUtils' +import { identifyGeoJSONFeatureAt } from '@/utils/identifyOnVectorLayer' import log from '@/utils/logging' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import '@geoblocks/cesium-compass' @@ -461,25 +461,19 @@ export default { ) let objects = this.viewer.scene.drillPick(event.position) - const geoJsonFeatures = {} const kmlFeatures = {} // if there is a GeoJSON layer currently visible, we will find it and search for features under the mouse cursor this.visiblePrimitiveLayers .filter((l) => l instanceof GeoAdminGeoJsonLayer) .forEach((geoJSonLayer) => { - objects - .filter((obj) => obj.primitive?.olLayer?.get('id') === geoJSonLayer.getID()) - .forEach((obj) => { - const feature = obj.primitive.olFeature - if (!geoJsonFeatures[feature.getId()]) { - geoJsonFeatures[feature.getId()] = createGeoJSONFeature( - obj.primitive.olFeature, - geoJSonLayer, - feature.getGeometry() - ) - } - }) - features.push(...Object.values(geoJsonFeatures)) + features.push( + ...identifyGeoJSONFeatureAt( + geoJSonLayer, + event.position, + this.projection, + this.resolution + ) + ) }) this.visiblePrimitiveLayers .filter((l) => l instanceof KMLLayer) diff --git a/src/modules/map/components/common/mouse-click.composable.js b/src/modules/map/components/common/mouse-click.composable.js index 27e07b3d6..75ed61de1 100644 --- a/src/modules/map/components/common/mouse-click.composable.js +++ b/src/modules/map/components/common/mouse-click.composable.js @@ -1,5 +1,6 @@ import LayerTypes from '@/api/layers/LayerTypes.enum' import { ClickInfo, ClickType } from '@/store/modules/map.store' +import { identifyGeoJSONFeatureAt, identifyKMLFeatureAt } from '@/utils/identifyOnVectorLayer' import { computed } from 'vue' import { useStore } from 'vuex' @@ -18,6 +19,8 @@ export function useMouseOnMap() { const visibleKMLLayers = computed(() => store.getters.visibleLayers.filter((layer) => layer.type === LayerTypes.KML) ) + const currentMapResolution = computed(() => store.getters.resolution) + const currentProjection = computed(() => store.state.position.projection) /** * @param {[Number, Number]} screenPosition @@ -42,12 +45,26 @@ export function useMouseOnMap() { if (!hasPointerDownTriggeredLocationPopup && isStillOnStartingPosition) { const features = [] // if there is a GeoJSON layer currently visible, we will find it and search for features under the mouse cursor - visibleGeoJsonLayers.value.forEach((_geoJSonLayer) => { - // TODO: implements OpenLayers-free feature identification + visibleGeoJsonLayers.value.forEach((geoJSonLayer) => { + features.push( + ...identifyGeoJSONFeatureAt( + geoJSonLayer, + coordinate, + currentProjection.value, + currentMapResolution.value + ) + ) }) // same for KML layers - visibleKMLLayers.value.forEach((_kmlLayer) => { - // TODO: implements OpenLayers-free feature identification + visibleKMLLayers.value.forEach((kmlLayer) => { + features.push( + ...identifyKMLFeatureAt( + kmlLayer.kmlData, + coordinate, + currentProjection.value, + currentMapResolution.value + ) + ) }) store.dispatch( 'click', diff --git a/src/store/modules/features.store.js b/src/store/modules/features.store.js index a6259c62b..83a07ad9e 100644 --- a/src/store/modules/features.store.js +++ b/src/store/modules/features.store.js @@ -7,7 +7,7 @@ const getSelectedFeatureWithId = (state, featureId) => { export default { state: { - /** @type Array */ + /** @type Array */ selectedFeatures: [], }, getters: { @@ -22,7 +22,8 @@ export default { * tells the store which features are selected (it does not select the features by itself) * * @param commit - * @param {Feature[]} features A list of feature we want to highlight/select on the map + * @param {SelectableFeature[]} features A list of feature we want to highlight/select on + * the map */ setSelectedFeatures({ commit }, features) { if (Array.isArray(features)) { diff --git a/src/utils/geoJsonUtils.js b/src/utils/geoJsonUtils.js index fddd1d2f7..02153b4d8 100644 --- a/src/utils/geoJsonUtils.js +++ b/src/utils/geoJsonUtils.js @@ -27,7 +27,7 @@ export default function reprojectGeoJsonData(geoJsonData, toProjection, fromProj } } else if (toProjection instanceof CoordinateSystem) { // according to the IETF reference, if nothing is said about the projection used, it should be WGS84 - reprojectedGeoJSON = reproject(this.geojsonData, WGS84.epsg, toProjection.epsg) + reprojectedGeoJSON = reproject(geoJsonData, WGS84.epsg, toProjection.epsg) } return reprojectedGeoJSON } diff --git a/src/utils/identifyOnVectorLayer.js b/src/utils/identifyOnVectorLayer.js new file mode 100644 index 000000000..86d3c61fa --- /dev/null +++ b/src/utils/identifyOnVectorLayer.js @@ -0,0 +1,108 @@ +import { LayerFeature } from '@/api/features.api' +import { WGS84 } from '@/utils/coordinates/coordinateSystems' +import reprojectGeoJsonData from '@/utils/geoJsonUtils' +import log from '@/utils/logging' +import distance from '@turf/distance' +import { point } from '@turf/helpers' +import proj4 from 'proj4' + +const pixelToleranceForIdentify = 10 + +/** + * Finds and returns all features, from the given GeoJSON layer, that are under or close to the + * given coordinate (we require the map resolution as input, so that we may calculate a 10-pixels + * tolerance for feature identification) + * + * This means we do not require OpenLayers to perform this search anymore, and that this code can be + * used in any mapping framework. + * + * @param {GeoAdminGeoJsonLayer} geoJsonLayer The GeoJSON layer in which we want to find feature at + * the given coordinate. This layer must have its geoJsonData loaded in order for this + * identification of feature to work properly (this function will not load the data if it is + * missing) + * @param {[Number, Number]} coordinate Where we want to find features ([x, y]) + * @param {CoordinateSystem} projection The projection used to describe the coordinate where we want + * to search for feature + * @param {Number} resolution The current map resolution, in meters/pixel. Used to calculate a + * tolerance of 10 pixels around the given coordinate. + * @returns {SelectableFeature[]} The feature found at the coordinate, or an empty array if none + * were found + */ +export function identifyGeoJSONFeatureAt(geoJsonLayer, coordinate, projection, resolution) { + const features = [] + // if there is a GeoJSON layer currently visible, we will find it and search for features under the mouse cursor + const coordinateWGS84 = point(proj4(projection.epsg, WGS84.epsg, coordinate)) + // to use turf functions, we need to have lat/lon (WGS84) coordinates + const reprojectedGeoJSON = reprojectGeoJsonData(geoJsonLayer.geoJsonData, WGS84, projection) + if (!reprojectedGeoJSON) { + log.error( + `Unable to reproject GeoJSON data in order to find features at coordinates`, + geoJsonLayer.getID(), + coordinate + ) + return [] + } + const matchingFeatures = reprojectedGeoJSON.features + .filter((feature) => { + const distanceWithClick = distance( + coordinateWGS84, + point(feature.geometry.coordinates), + { + units: 'meters', + } + ) + return distanceWithClick <= pixelToleranceForIdentify * resolution + }) + .map((feature) => { + // back to the starting projection + feature.geometry.coordinates = proj4( + WGS84.epsg, + projection.epsg, + feature.geometry.coordinates + ) + return new LayerFeature( + geoJsonLayer, + feature.id, + feature.properties.station_name || feature.id, + `
+
+ ${geoJsonLayer.name} +
+
+ ${feature.properties.description} +
+
`, + proj4(WGS84.epsg, projection.epsg, feature.geometry.coordinates), + null, + feature.geometry + ) + }) + if (matchingFeatures?.length > 0) { + features.push(...matchingFeatures) + } + return features +} + +/** + * Finds and returns all features, from the given KML layer, that are under or close to the given + * coordinate (we require the map resolution as input, so that we may calculate a 10-pixels + * tolerance for feature identification) + * + * This means we do not require OpenLayers to perform this search anymore, and that this code can be + * used in any mapping framework. + * + * @param {KMLLayer} _kmlLayer The KML layer in which we want to find feature at the given + * coordinate. This layer must have its kmlData loaded in order for this identification of feature + * to work properly (this function will not load the data if it is missing) + * @param {[Number, Number]} _coordinate Where we want to find features ([x, y]) + * @param {CoordinateSystem} _projection The projection used to describe the coordinate where we + * want to search for feature + * @param {Number} _resolution The current map resolution, in meters/pixel. Used to calculate a + * tolerance of 10 pixels around the given coordinate. + * @returns {SelectableFeature[]} The feature found at the coordinate, or an empty array if none + * were found + */ +export function identifyKMLFeatureAt(_kmlLayer, _coordinate, _projection, _resolution) { + // TODO : implement KML layer feature identification + return [] +} diff --git a/src/utils/layerUtils.js b/src/utils/layerUtils.js index 3566e6331..80fe871eb 100644 --- a/src/utils/layerUtils.js +++ b/src/utils/layerUtils.js @@ -1,7 +1,5 @@ -import { YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA } from '@/api/layers/LayerTimeConfigEntry.class' import GeoAdminWMTSLayer from '@/api/layers/GeoAdminWMTSLayer.class' -import { LayerFeature } from '@/api/features.api' -import log from '@/utils/logging' +import { YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA } from '@/api/layers/LayerTimeConfigEntry.class' export class ActiveLayerConfig { /** @@ -43,37 +41,3 @@ export function getTimestampFromConfig(config, previewYear) { } return config instanceof GeoAdminWMTSLayer ? null : '' } - -/** - * Describes a GeoJSON feature from the backend - * - * For GeoJSON features, there's a catch as they only provide us with the inner tooltip content we - * have to wrap it around the "usual" wrapper from the backend (not very fancy but otherwise the - * look and feel is different from a typical backend tooltip) - * - * @param feature - * @param geoJsonLayer - * @param [geometry] - * @returns {LayerFeature} - */ -export function createGeoJSONFeature(feature, geoJsonLayer, geometry) { - const featureGeometry = feature.getGeometry() - const geoJsonFeature = new LayerFeature( - geoJsonLayer, - geoJsonLayer.getID(), - geoJsonLayer.name, - `
-
- ${geoJsonLayer.name} -
-
- ${feature.get('description')} -
-
`, - featureGeometry.flatCoordinates, - featureGeometry.getExtent(), - geometry - ) - log.debug('GeoJSON feature found', geoJsonFeature) - return geoJsonFeature -}