Skip to content

Commit

Permalink
Merge pull request #875 from geoadmin/feat_PB-538_split_profile_reque…
Browse files Browse the repository at this point in the history
…st_too_long

PB-538 : split profile too long for backend into chunks
  • Loading branch information
pakb authored May 30, 2024
2 parents 63fdc69 + 3c15287 commit 15965e8
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 79 deletions.
59 changes: 51 additions & 8 deletions src/api/__tests__/profile.api.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { expect } from 'chai'
import { describe, it } from 'vitest'

import ElevationProfile from '@/api/profile/ElevationProfile.class'
import ElevationProfilePoint from '@/api/profile/ElevationProfilePoint.class'
import ElevationProfileSegment from '@/api/profile/ElevationProfileSegment.class'
import { splitIfTooManyPoints } from '@/api/profile/profile.api.js'

const testProfile = new ElevationProfile([
new ElevationProfileSegment([
new ElevationProfilePoint([0, 0], 0, 100),
new ElevationProfilePoint([0, 50], 50, 210),
new ElevationProfilePoint([0, 150], 150, 90),
new ElevationProfilePoint([50, 150], 200, 200),
{ coordinate: [0, 0], dist: 0, elevation: 100, hasElevationData: true },
{ coordinates: [0, 50], dist: 50, elevation: 210, hasElevationData: true },
{ coordinates: [0, 150], dist: 150, elevation: 90, hasElevationData: true },
{ coordinates: [50, 150], dist: 200, elevation: 200, hasElevationData: true },
]),
])

Expand All @@ -19,7 +19,7 @@ describe('Profile calculation', () => {
const profileWithoutElevationData = new ElevationProfile([
new ElevationProfileSegment([
// using 1 everywhere (except elevation) in order to check it won't be used by calculations
new ElevationProfilePoint([1, 1], 1, null),
{ coordinate: [1, 1], dist: 1, elevation: null, hasElevationData: false },
]),
])
expect(profileWithoutElevationData.hasElevationData).to.be.false
Expand All @@ -38,8 +38,8 @@ describe('Profile calculation', () => {
const malformedProfile = new ElevationProfile([
new ElevationProfileSegment([
// using 1 everywhere (except elevation) in order to check it won't be used by calculations
new ElevationProfilePoint([1, 1], 0, null),
new ElevationProfilePoint([2, 1], 1, 1),
{ coordinate: [1, 1], dist: 0, elevation: null, hasElevationData: false },
{ coordinate: [2, 1], dist: 1, elevation: 1, hasElevationData: true },
]),
])
expect(malformedProfile.hasElevationData).to.be.false
Expand Down Expand Up @@ -74,3 +74,46 @@ describe('Profile calculation', () => {
expect(testProfile.slopeDistance).to.approximately(397.86, 0.01)
})
})

describe('splitIfTooManyPoints', () => {
/**
* @param {Number} pointsCount
* @returns {CoordinatesChunk}
*/
function generateChunkWith(pointsCount) {
const coordinates = []
for (let i = 0; i < pointsCount; i++) {
coordinates.push([0, i])
}
return {
coordinates,
isWithinBounds: true,
}
}

it('does not split a segment that does not contain more point than the limit', () => {
const result = splitIfTooManyPoints([generateChunkWith(3000)])
expect(result).to.be.an('Array').lengthOf(1)
expect(result[0].coordinates).to.be.an('Array').lengthOf(3000)
})
it('splits if one coordinates above the limit', () => {
const result = splitIfTooManyPoints([generateChunkWith(3001)])
expect(result).to.be.an('Array').lengthOf(2)
expect(result[0].coordinates).to.be.an('Array').lengthOf(3000)
expect(result[1].coordinates).to.be.an('Array').lengthOf(1)
})
it('creates as many sub-chunks as necessary', () => {
const result = splitIfTooManyPoints([generateChunkWith(3000 * 4 + 123)])
expect(result).to.be.an('Array').lengthOf(5)
for (let i = 0; i < 4; i++) {
expect(result[i].coordinates).to.be.an('Array').lengthOf(3000)
}
expect(result[4].coordinates).to.be.an('Array').lengthOf(123)
})
it('does not fail if the given chunk is empty or invalid', () => {
expect(splitIfTooManyPoints(null)).to.be.null
expect(splitIfTooManyPoints(undefined)).to.be.null
expect(splitIfTooManyPoints({})).to.be.null
expect(splitIfTooManyPoints([])).to.be.an('Array').lengthOf(0)
})
})
30 changes: 0 additions & 30 deletions src/api/profile/ElevationProfilePoint.class.js

This file was deleted.

98 changes: 75 additions & 23 deletions src/api/profile/profile.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import axios from 'axios'
import proj4 from 'proj4'

import ElevationProfile from '@/api/profile/ElevationProfile.class'
import ElevationProfilePoint from '@/api/profile/ElevationProfilePoint.class'
import ElevationProfileSegment from '@/api/profile/ElevationProfileSegment.class'
import { API_SERVICE_ALTI_BASE_URL } from '@/config'
import { LV95 } from '@/utils/coordinates/coordinateSystems'
Expand All @@ -16,16 +15,60 @@ export class ProfileError {
}
}

/**
* @typedef ElevationProfilePoint
* @property {Number} dist Distance from first to current point (relative to the whole profile, not
* by segments)
* @property {[Number, Number]} coordinate Coordinate of this point in the current projection
* @property {Number | null} elevation In the COMB elevation model
* @property {Boolean} hasElevationData True if some elevation data are present
*/

/**
* How many coordinate we will let a chunk have before splitting it into multiple chunks
*
* Backend has a hard limit at 5k, we take a conservative approach with 3k.
*
* @type {number}
*/
const MAX_CHUNK_LENGTH = 3000

/**
* @param {CoordinatesChunk[] | null} [chunks]
* @returns {null | CoordinatesChunk[]}
*/
export function splitIfTooManyPoints(chunks = null) {
if (!Array.isArray(chunks)) {
return null
}
return chunks.flatMap((chunk) => {
if (chunk.coordinates.length <= MAX_CHUNK_LENGTH) {
return chunk
}
const subChunks = []
for (let i = 0; i < chunk.coordinates.length; i += MAX_CHUNK_LENGTH) {
subChunks.push({
isWithinBounds: chunk.isWithinBounds,
coordinates: chunk.coordinates.slice(i, i + MAX_CHUNK_LENGTH),
})
}
return subChunks
})
}

function parseProfileFromBackendResponse(backendResponse, startingDist, outputProjection) {
const points = []
backendResponse.forEach((rawData) => {
let coordinates = [rawData.easting, rawData.northing]
let coordinate = [rawData.easting, rawData.northing]
if (outputProjection.epsg !== LV95.epsg) {
coordinates = proj4(LV95.epsg, outputProjection.epsg, coordinates)
coordinate = proj4(LV95.epsg, outputProjection.epsg, coordinate)
}
points.push(
new ElevationProfilePoint(coordinates, startingDist + rawData.dist, rawData.alts.COMB)
)
points.push({
coordinate,
dist: startingDist + rawData.dist,
elevation: rawData.alts.COMB,
hasElevationData: rawData.alts.COMB !== null,
})
})
return new ElevationProfileSegment(points)
}
Expand All @@ -35,7 +78,7 @@ function parseProfileFromBackendResponse(backendResponse, startingDist, outputPr
* @param {[Number, Number] | null} startingPoint
* @param {Number} startingDist
* @param {CoordinateSystem} outputProjection
* @returns {ElevationProfile}
* @returns {Object} Raw profile backend response for this chunk
* @throws ProfileError
*/
export async function getProfileDataForChunk(chunk, startingPoint, startingDist, outputProjection) {
Expand All @@ -59,11 +102,7 @@ export async function getProfileDataForChunk(chunk, startingPoint, startingDist,
// or so of the LV95 bounds, resulting in an empty profile being sent by the backend even though our
// coordinates were inbound (hence the dataForChunk.data.length > 2 check)
if (dataForChunk?.data && dataForChunk.data.length > 2) {
return parseProfileFromBackendResponse(
dataForChunk.data,
startingDist,
outputProjection
)
return dataForChunk.data
} else {
log.error('Incorrect/empty response while getting profile', dataForChunk)
throw new ProfileError(
Expand Down Expand Up @@ -114,10 +153,12 @@ export async function getProfileDataForChunk(chunk, startingPoint, startingDist,
}
lastDist = dist
lastCoordinate = coordinate
return new ElevationProfilePoint(
return {
coordinate,
outputProjection.roundCoordinateValue(dist)
)
dist: outputProjection.roundCoordinateValue(dist),
elevation: null,
hasElevationData: false,
}
}),
])
}
Expand Down Expand Up @@ -147,7 +188,7 @@ export default async (coordinates, projection) => {
)
}
const segments = []
let coordinateChunks = LV95.bounds.splitIfOutOfBounds(coordinatesInLV95)
let coordinateChunks = splitIfTooManyPoints(LV95.bounds.splitIfOutOfBounds(coordinatesInLV95))
if (!coordinateChunks) {
log.error('No chunks found, no profile data could be fetched', coordinatesInLV95)
throw new ProfileError(
Expand All @@ -157,13 +198,24 @@ export default async (coordinates, projection) => {
}
let lastCoordinate = null
let lastDist = 0
for (const chunk of coordinateChunks) {
const segment = await getProfileDataForChunk(chunk, lastCoordinate, lastDist, projection)
if (segment) {
const newSegmentLastPoint = segment.points.slice(-1)[0]
lastCoordinate = newSegmentLastPoint.coordinate
lastDist = newSegmentLastPoint.dist
segments.push(segment)
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)
}
}
return new ElevationProfile(segments)
Expand Down
18 changes: 0 additions & 18 deletions tests/cypress/tests-e2e/drawing.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -1170,24 +1170,6 @@ describe('Drawing module tests', () => {
cy.get('[data-cy="infobox-close"]').click()
cy.get('[data-cy="infobox"]').should('not.exist')
})
it('shows an error message when the profile is too big for the backend', () => {
cy.goToDrawing()
cy.intercept('**/rest/services/profile.json**', (req) => {
req.reply({
statusCode: 413,
fixture: 'service-alti/profile.too.big.error.fixture.json',
})
}).as('error-profile-too-long')
cy.clickDrawingTool(EditableFeatureTypes.LINEPOLYGON)

cy.get('[data-cy="ol-map"]').click(200, 200)
cy.get('[data-cy="ol-map"]').dblclick(150, 200)
cy.wait('@error-profile-too-long')
cy.get('[data-cy="profile-popup-content"]').should('be.visible')
cy.get('[data-cy="profile-error-message"]')
.should('be.visible')
.should('have.class', 'text-danger')
})
it('can switch from floating edit popup to back at bottom', () => {
cy.goToDrawing()
// to avoid overlaping with the map footer and the floating tooltip, increase the vertical size.
Expand Down

0 comments on commit 15965e8

Please sign in to comment.