From c5d375a1079ad558856d0733c393ef492894fc46 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Thu, 14 Nov 2024 15:32:16 +0100 Subject: [PATCH] PB-849: show segments multi segment file --- src/api/profile/ElevationProfile.class.js | 61 +++++++++---- src/api/profile/profile.api.js | 89 +++++++++++-------- src/modules/i18n/locales/de.json | 1 + src/modules/i18n/locales/en.json | 1 + src/modules/i18n/locales/fr.json | 1 + src/modules/i18n/locales/it.json | 1 + src/modules/i18n/locales/rm.json | 1 + .../FeatureElevationProfilePlot.vue | 33 ++++++- .../components/FeatureListCategoryItem.vue | 4 +- src/store/modules/features.store.js | 25 ++++-- tests/cypress/tests-e2e/importToolFile.cy.js | 3 + 11 files changed, 153 insertions(+), 67 deletions(-) diff --git a/src/api/profile/ElevationProfile.class.js b/src/api/profile/ElevationProfile.class.js index 6445024258..18d1029400 100644 --- a/src/api/profile/ElevationProfile.class.js +++ b/src/api/profile/ElevationProfile.class.js @@ -5,16 +5,42 @@ import { LineString } from 'ol/geom' * calculation related to profile (hiking time, slop/distance, etc...) */ export default class ElevationProfile { - /** @param {ElevationProfileSegment[]} segments */ + /** + * Creates an instance of ElevationProfile. + * + * @param {ElevationProfileSegment[]} segments - An array of elevation profile segments. + * @param {number} _activeSegmentIndex - The index of the active segment. + */ constructor(segments) { - /** @type {ElevationProfileSegment[]} */ this.segments = [...segments] + this._activeSegmentIndex = 0 } get points() { return this.segments.flatMap((segment) => segment.points) } + get segmentPoints() { + return this.segments[this._activeSegmentIndex].points + } + + /** @returns {Number} */ + get segmentsCount() { + return this.segments.length + } + + /** @returns {Number} */ + get activeSegmentIndex() { + return this._activeSegmentIndex + } + + set activeSegmentIndex(index) { + if (index < 0 || index >= this.segmentsCount) { + return + } + this._activeSegmentIndex = index + } + /** @returns {Number} */ get length() { return this.points.length @@ -38,7 +64,7 @@ export default class ElevationProfile { if (!this.hasDistanceData) { return 0 } - return this.segments.slice(-1)[0].maxDist + return this.segments[this._activeSegmentIndex].maxDist } /** @returns {Number} */ @@ -46,7 +72,9 @@ export default class ElevationProfile { if (!this.hasElevationData) { return 0 } - return Math.max(...this.points.map((point) => point.elevation)) + return Math.max( + ...this.segments[this._activeSegmentIndex].points.map((point) => point.elevation) + ) } /** @returns {Number} */ @@ -55,7 +83,9 @@ export default class ElevationProfile { return 0 } return Math.min( - ...this.points.filter((point) => point.hasElevationData).map((point) => point.elevation) + ...this.segments[this._activeSegmentIndex].points + .filter((point) => point.hasElevationData) + .map((point) => point.elevation) ) } @@ -64,26 +94,23 @@ export default class ElevationProfile { if (!this.hasElevationData) { return 0 } - return this.points.slice(-1)[0].elevation - this.points[0].elevation + return ( + this.segments[this._activeSegmentIndex].points.slice(-1)[0].elevation - + this.segments[this._activeSegmentIndex].points[0].elevation + ) } get totalAscent() { - return this.segments.reduce((totalAscent, currentSegment) => { - return totalAscent + currentSegment.totalAscent - }, 0) + return this.segments[this._activeSegmentIndex].totalAscent } get totalDescent() { - return this.segments.reduce((totalDescent, currentSegment) => { - return totalDescent + currentSegment.totalDescent - }, 0) + return this.segments[this._activeSegmentIndex].totalDescent } /** @returns {Number} Sum of slope/surface distances (distance on the ground) */ get slopeDistance() { - return this.segments.reduce((slopeDistance, currentSegment) => { - return slopeDistance + currentSegment.slopeDistance - }, 0) + return this.segments[this._activeSegmentIndex].slopeDistance } get coordinates() { @@ -105,8 +132,6 @@ export default class ElevationProfile { * @returns {number} Estimation of hiking time for this profile */ get hikingTime() { - return this.segments.reduce((hikingTime, currentSegment) => { - return hikingTime + currentSegment.hikingTime - }, 0) + return this.segments[this._activeSegmentIndex].hikingTime } } diff --git a/src/api/profile/profile.api.js b/src/api/profile/profile.api.js index 90eba44349..a951bb3c69 100644 --- a/src/api/profile/profile.api.js +++ b/src/api/profile/profile.api.js @@ -166,56 +166,71 @@ export async function getProfileDataForChunk(chunk, startingPoint, startingDist, /** * Gets profile from https://api3.geo.admin.ch/services/sdiservices.html#profile * - * @param {[Number, Number][]} coordinates Coordinates, expressed in the given projection, from - * which we want the profile + * @param {[Number, Number][]} profileCoordinates Coordinates, expressed in the given projection, + * from which we want the profile * @param {CoordinateSystem} projection The projection used to describe the coordinates * @returns {ElevationProfile | null} The profile, or null if there was no valid data to produce a * profile * @throws ProfileError */ -export default async (coordinates, projection) => { - if (!coordinates || coordinates.length === 0) { +export default async (profileCoordinates, projection) => { + if (!profileCoordinates || profileCoordinates.length === 0) { const errorMessage = `Coordinates not provided` log.error(errorMessage) throw new ProfileError(errorMessage, 'could_not_generate_profile') } - // the service only works with LV95 coordinate, we have to transform them if they are not in this projection - // removing any 3d dimension that could come from OL - let coordinatesInLV95 = removeZValues(unwrapGeometryCoordinates(coordinates)) - if (projection.epsg !== LV95.epsg) { - coordinatesInLV95 = coordinates.map((coordinate) => - proj4(projection.epsg, LV95.epsg, coordinate) - ) - } const segments = [] - let coordinateChunks = splitIfTooManyPoints(LV95.bounds.splitIfOutOfBounds(coordinatesInLV95)) - if (!coordinateChunks) { - log.error('No chunks found, no profile data could be fetched', coordinatesInLV95) - throw new ProfileError( - 'No chunks found, no profile data could be fetched', - 'could_not_generate_profile' - ) + const hasDoubleNestedArray = (arr) => + arr.some((item) => Array.isArray(item) && item.some((subItem) => Array.isArray(subItem))) + + // if the profileCoordinates is not a double nested array, we make it one + // segmented files have a double nested array, but not all files or self made drawings + // so we have to make sure we have a double nested array and then iterate over it + if (!hasDoubleNestedArray(profileCoordinates)) { + profileCoordinates = [profileCoordinates] } - let lastCoordinate = null - let lastDist = 0 - const requestsForChunks = coordinateChunks.map((chunk) => - getProfileDataForChunk(chunk, lastCoordinate, lastDist, projection) - ) - for (const chunkResponse of await Promise.allSettled(requestsForChunks)) { - if (chunkResponse.status === 'fulfilled') { - const segment = parseProfileFromBackendResponse( - chunkResponse.value, - lastDist, - projection + for (const coordinates of profileCoordinates) { + // The service only works with LV95 coordinate, we have to transform them if they are not in this projection + // removing any 3d dimension that could come from OL + let coordinatesInLV95 = removeZValues(unwrapGeometryCoordinates(coordinates)) + if (projection.epsg !== LV95.epsg) { + coordinatesInLV95 = coordinates.map((coordinate) => + proj4(projection.epsg, LV95.epsg, coordinate) ) - if (segment) { - const newSegmentLastPoint = segment.points.slice(-1)[0] - lastCoordinate = newSegmentLastPoint.coordinate - lastDist = newSegmentLastPoint.dist - segments.push(segment) + } + let coordinateChunks = splitIfTooManyPoints( + LV95.bounds.splitIfOutOfBounds(coordinatesInLV95) + ) + + if (!coordinateChunks) { + log.error('No chunks found, no profile data could be fetched', coordinatesInLV95) + throw new ProfileError( + 'No chunks found, no profile data could be fetched', + 'could_not_generate_profile' + ) + } + let lastCoordinate = null + let lastDist = 0 + const requestsForChunks = coordinateChunks.map((chunk) => + getProfileDataForChunk(chunk, lastCoordinate, lastDist, projection) + ) + + for (const chunkResponse of await Promise.allSettled(requestsForChunks)) { + if (chunkResponse.status === 'fulfilled') { + const segment = parseProfileFromBackendResponse( + chunkResponse.value, + lastDist, + projection + ) + if (segment) { + const newSegmentLastPoint = segment.points.slice(-1)[0] + lastCoordinate = newSegmentLastPoint.coordinate + lastDist = newSegmentLastPoint.dist + segments.push(segment) + } + } else { + log.error('Error while getting profile for chunk', chunkResponse.reason?.message) } - } else { - log.error('Error while getting profile for chunk', chunkResponse.reason?.message) } } return new ElevationProfile(segments) diff --git a/src/modules/i18n/locales/de.json b/src/modules/i18n/locales/de.json index 97514d61a4..0ae2aff4da 100644 --- a/src/modules/i18n/locales/de.json +++ b/src/modules/i18n/locales/de.json @@ -496,6 +496,7 @@ "profile_no_data": "kein Datum", "profile_poi_down": "Tiefster Punkt", "profile_poi_up": "Höchster Punkt", + "profile_segment": "Segment {segmentNumber}", "profile_slope_distance": "Wegstrecke", "profile_title": "Profil", "profile_too_many_points_error": "Die Profilanfrage war zu gross und konnte nicht verarbeitet werden.", diff --git a/src/modules/i18n/locales/en.json b/src/modules/i18n/locales/en.json index 63db62d355..d5d6290d3d 100644 --- a/src/modules/i18n/locales/en.json +++ b/src/modules/i18n/locales/en.json @@ -496,6 +496,7 @@ "profile_no_data": "No data", "profile_poi_down": "Lowest point", "profile_poi_up": "Highest point", + "profile_segment": "Segment {segmentNumber}", "profile_slope_distance": "Path distance", "profile_title": "Profile", "profile_too_many_points_error": "The profile request was too big and could not be processed.", diff --git a/src/modules/i18n/locales/fr.json b/src/modules/i18n/locales/fr.json index 588c7fbd43..30ed182c10 100644 --- a/src/modules/i18n/locales/fr.json +++ b/src/modules/i18n/locales/fr.json @@ -496,6 +496,7 @@ "profile_no_data": "Aucune donnée", "profile_poi_down": "Point le plus bas", "profile_poi_up": "Point culminant", + "profile_segment": "Segment {segmentNumber}", "profile_slope_distance": "Longeur chemin ", "profile_title": "Profil", "profile_too_many_points_error": "La demande de profil était trop volumineuse et n'a pas pu être traitée.", diff --git a/src/modules/i18n/locales/it.json b/src/modules/i18n/locales/it.json index 43beda01a8..78a12b8d64 100644 --- a/src/modules/i18n/locales/it.json +++ b/src/modules/i18n/locales/it.json @@ -496,6 +496,7 @@ "profile_no_data": "Nessun dato", "profile_poi_down": "Punto più basso", "profile_poi_up": "Punto più alto", + "profile_segment": "Segmento {segmentNumber}", "profile_slope_distance": "Lunghezza strada ", "profile_title": "Profilo", "profile_too_many_points_error": "La richiesta del profilo era troppo grande e non è stato possibile elaborarla.", diff --git a/src/modules/i18n/locales/rm.json b/src/modules/i18n/locales/rm.json index ea74e8e19d..fdbe56905d 100644 --- a/src/modules/i18n/locales/rm.json +++ b/src/modules/i18n/locales/rm.json @@ -494,6 +494,7 @@ "profile_no_data": "Nagina data", "profile_poi_down": "Punct il pli bass", "profile_poi_up": "Punct il pli aut", + "profile_segment": "Segment {segmentNumber}", "profile_slope_distance": "Traject", "profile_title": "Profil", "profile_too_many_points_error": "La dumonda dal profil era memia gronda e n'ha betg pudì vegnir elavurada.", diff --git a/src/modules/infobox/components/FeatureElevationProfilePlot.vue b/src/modules/infobox/components/FeatureElevationProfilePlot.vue index db2d22ffa5..ab77e9b0be 100644 --- a/src/modules/infobox/components/FeatureElevationProfilePlot.vue +++ b/src/modules/infobox/components/FeatureElevationProfilePlot.vue @@ -5,6 +5,21 @@ @mouseenter="startPositionTracking" @mouseleave="stopPositionTracking" > +
+ +
import { resetZoom } from 'chartjs-plugin-zoom' import { Line as LineChart } from 'vue-chartjs' -import { mapState } from 'vuex' +import { mapActions, mapState } from 'vuex' import ElevationProfile from '@/api/profile/ElevationProfile.class' import FeatureElevationProfilePlotCesiumBridge from '@/modules/infobox/FeatureElevationProfilePlotCesiumBridge.vue' @@ -85,6 +100,7 @@ const GAP_BETWEEN_TOOLTIP_AND_PROFILE = 12 //px * @property {Number} elevation * @property {Boolean} hasElevationData */ +const dispatcher = { dispatcher: 'FeatureElevationProfilePlot.vue' } /** * Encapsulate ChartJS profile plot generation. @@ -213,7 +229,7 @@ export default { datasets: [ { label: `${this.$t('elevation')}`, - data: this.elevationProfile.points, + data: this.elevationProfile.segmentPoints, parsing: { xAxisKey: 'dist', yAxisKey: 'elevation', @@ -384,6 +400,7 @@ export default { } }, methods: { + ...mapActions(['setActiveSegmentIndex']), startPositionTracking() { this.track = true }, @@ -405,6 +422,12 @@ export default { resizeChart() { this.$refs.chart.chart.resize() }, + activateSegmentIndex(index) { + this.setActiveSegmentIndex({ + index, + ...dispatcher, + }) + }, }, } @@ -426,6 +449,7 @@ $tooltip-width: 170px; .profile-graph { width: 100%; + flex-direction: column; &-container { overflow: hidden; @@ -434,6 +458,11 @@ $tooltip-width: 170px; pointer-events: auto; } } + +.segment-container { + overflow-x: auto; +} + .profile-tooltip { width: $tooltip-width; height: $tooltip-height; diff --git a/src/modules/infobox/components/FeatureListCategoryItem.vue b/src/modules/infobox/components/FeatureListCategoryItem.vue index 5d599cfe19..678d602264 100644 --- a/src/modules/infobox/components/FeatureListCategoryItem.vue +++ b/src/modules/infobox/components/FeatureListCategoryItem.vue @@ -7,6 +7,7 @@ import EditableFeature from '@/api/features/EditableFeature.class' import LayerFeature from '@/api/features/LayerFeature.class' import FeatureDetail from '@/modules/infobox/components/FeatureDetail.vue' import ShowGeometryProfileButton from '@/modules/infobox/components/ShowGeometryProfileButton.vue' +import { canFeatureShowProfile } from '@/store/modules/features.store' import TextTruncate from '@/utils/components/TextTruncate.vue' import ZoomToExtentButton from '@/utils/components/ZoomToExtentButton.vue' @@ -32,6 +33,7 @@ const { name, item, showContentByDefault } = toRefs(props) const content = ref(null) const featureTitle = ref(null) const showContent = ref(!!showContentByDefault.value) +const canDisplayProfile = computed(() => canFeatureShowProfile(item.value)) const store = useStore() const isHighlightedFeature = computed( @@ -107,7 +109,7 @@ function showContentAndScrollIntoView(event) { @mouseleave.passive="clearHighlightedFeature" > -
+
diff --git a/src/store/modules/features.store.js b/src/store/modules/features.store.js index 6c2b25ce60..a0bdf4c276 100644 --- a/src/store/modules/features.store.js +++ b/src/store/modules/features.store.js @@ -17,6 +17,11 @@ import { allStylingColors, allStylingSizes } from '@/utils/featureStyleUtils' import { transformIntoTurfEquivalent } from '@/utils/geoJsonUtils' import log from '@/utils/logging' +/** @param {SelectableFeature} feature */ +export function canFeatureShowProfile(feature) { + return feature?.geometry?.type && !['Point'].includes(feature.geometry.type) +} + const getEditableFeatureWithId = (state, featureId) => { return state.selectedEditableFeatures.find( (selectedFeature) => selectedFeature.id === featureId @@ -401,6 +406,11 @@ export default { commit('changeFeatureTitle', { feature: selectedFeature, title, dispatcher }) } }, + setActiveSegmentIndex({ commit, state }, { index, dispatcher }) { + if (state.profileData && state.profileData.activeSegmentIndex !== index) { + commit('setActiveSegmentIndex', { index: index, dispatcher }) + } + }, /** * Changes the description of the feature. Only change the description if the feature is * editable and part of the currently selected features @@ -592,7 +602,7 @@ export default { if (feature === null) { commit('setProfileFeature', { feature: null, dispatcher }) commit('setProfileData', { data: null, dispatcher }) - } else { + } else if (canFeatureShowProfile(feature)) { if (state.profileRequestError) { commit('setProfileRequestError', { error: null, dispatcher }) } @@ -613,14 +623,6 @@ export default { } else { coordinates = [...feature.geometry.coordinates] } - // unwrapping the set of coordinates if they come from a multi-feature type geometry - if ( - coordinates?.length && - coordinates.some(Array.isArray) && - coordinates[0].some((coordinate) => Array.isArray(coordinate)) - ) { - coordinates = coordinates.flat() - } getProfile(coordinates, rootState.position.projection) .then((profileData) => { commit('setProfileData', { data: profileData, dispatcher }) @@ -630,6 +632,8 @@ export default { commit('setProfileRequestError', { error: error, dispatcher }) }) } + } else { + log.warn('Geometry type not supported to show a profile, ignoring', feature) } }, /** @@ -702,6 +706,9 @@ export default { state.selectedFeaturesByLayerId = layerFeaturesByLayerId state.selectedEditableFeatures = [...drawingFeatures] }, + setActiveSegmentIndex(state, { index }) { + state.profileData.activeSegmentIndex = index + }, addSelectedFeatures(state, { featuresForLayer, features, featureCountForMoreData = 0 }) { featuresForLayer.features.push(...features) featuresForLayer.featureCountForMoreData = featureCountForMoreData diff --git a/tests/cypress/tests-e2e/importToolFile.cy.js b/tests/cypress/tests-e2e/importToolFile.cy.js index 7385151ea1..0f67dc441a 100644 --- a/tests/cypress/tests-e2e/importToolFile.cy.js +++ b/tests/cypress/tests-e2e/importToolFile.cy.js @@ -777,5 +777,8 @@ describe('The Import File Tool', () => { cy.get('[data-cy="profile-graph"]').trigger('mousemove', 'center') cy.get('[data-cy="profile-popup-tooltip"] .distance').should('contain.text', '2.5 m') cy.get('[data-cy="profile-popup-tooltip"] .elevation').should('contain.text', '1341.8 m') + cy.get('[data-cy="profile-segment-button-0"]').should('be.visible') + cy.get('[data-cy="profile-segment-button-1"]').should('be.visible') + cy.get('[data-cy="profile-segment-button-2"]').should('be.visible') }) })