Skip to content

Commit

Permalink
Merge pull request #1091 from geoadmin/feat-pb-1023-enable-search-ins…
Browse files Browse the repository at this point in the history
…ide-kml

PB-1023: enable search inside kml
  • Loading branch information
sommerfe authored Oct 24, 2024
2 parents f4a4a47 + 6451c51 commit fd26a8e
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 11 deletions.
143 changes: 143 additions & 0 deletions src/api/search.api.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import bbox from '@turf/bbox'
import center from '@turf/center'
import { points } from '@turf/helpers'
import axios from 'axios'
import proj4 from 'proj4'

import { extractOlFeatureCoordinates } from '@/api/features/features.api'
import LayerTypes from '@/api/layers/LayerTypes.enum'
import { getServiceSearchBaseUrl } from '@/config/baseUrl.config'
import i18n from '@/modules/i18n'
import CoordinateSystem from '@/utils/coordinates/CoordinateSystem.class'
import { LV95, WGS84 } from '@/utils/coordinates/coordinateSystems'
import { normalizeExtent } from '@/utils/coordinates/coordinateUtils'
import CustomCoordinateSystem from '@/utils/coordinates/CustomCoordinateSystem.class'
import LV95CoordinateSystem from '@/utils/coordinates/LV95CoordinateSystem.class'
import { parseGpx } from '@/utils/gpxUtils'
import { parseKml } from '@/utils/kmlUtils'
import log from '@/utils/logging'

const KML_GPX_SEARCH_FIELDS = ['name', 'description', 'id']

// API file that covers the backend endpoint http://api3.geo.admin.ch/services/sdiservices.html#search

/**
Expand Down Expand Up @@ -278,6 +288,137 @@ async function searchLayerFeatures(outputProjection, queryString, layer, lang, c
}
}

/**
* Searches for the query string in the feature inside the provided search fields
*
* @param {ol/Feature} feature
* @param {String} queryString
* @param {String[]} searchFields
* @returns {Boolean}
*/
function isQueryInFeature(feature, queryString, searchFields) {
const queryStringClean = queryString
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // replaces all special characters and accents
return searchFields.some((field) => {
const value = feature.values_[field]?.toString()
return !!value && value.trim().toLowerCase().includes(queryStringClean)
})
}

/**
* Searches for the query string in the layer
*
* @param {CoordinateSystem} outputProjection
* @param {String} queryString
* @param {GeoAdminLayer} layer
* @param {any} parseData Data needed in the parseFunction
* @param {Function} parseFunction Function to parse the data
* @returns {SearchResult[]}
*/
function searchFeatures(outputProjection, queryString, layer, parseData, parseFunction) {
try {
const features = parseFunction(parseData, outputProjection, [])
if (!features || !features.length) {
return []
}
const includedFeatures = features.filter((feature) =>
isQueryInFeature(feature, queryString, KML_GPX_SEARCH_FIELDS)
)
if (!includedFeatures.length) {
return []
}
return includedFeatures.map((feature) =>
createSearchResultFromLayer(layer, feature, outputProjection)
)
} catch (error) {
log.error(
`Failed to search layer features for layer ${layer.id}, fallback to empty result`,
error
)
return []
}
}

/**
* Searches for features in KML and GPX layers based on a query string and output projection
*
* @param {GeoAdminLayer[]} layersToSearch - The layers to search through
* @param {string} queryString - The query string to search for
* @param {string} outputProjection - The projection to use for the output
* @returns {SearchResult[]}
*/
function searchLayerFeaturesKMLGPX(layersToSearch, queryString, outputProjection) {
return layersToSearch.reduce((returnLayers, currentLayer) => {
if (currentLayer.type === LayerTypes.KML) {
return returnLayers.concat(
searchFeatures(outputProjection, queryString, currentLayer, currentLayer, parseKml)
)
}
if (currentLayer.type === LayerTypes.GPX) {
return returnLayers.concat(
...searchFeatures(
outputProjection,
queryString,
currentLayer,
currentLayer.gpxData,
parseGpx
)
)
}
return returnLayers
}, [])
}

/**
* Creates the SearchResult for a layer
*
* @param {GeoAdminLayer} layer
* @param {ol/Feature} feature
* @param {ol/extent|null} extent
* @param {CoordinateSystem} outputProjection
* @returns {SearchResult}
*/
function createSearchResultFromLayer(layer, feature, outputProjection) {
const featureName = feature.values_.name || layer.name || '' // this needs || to avoid using empty string when feature.values_.name is an empty string
const coordinates = extractOlFeatureCoordinates(feature)
const zoom = outputProjection.get1_25000ZoomLevel()

const coordinatePoints = points(coordinates)
const centerPoint = center(coordinatePoints)
const normalExtent = normalizeExtent(bbox(coordinatePoints))

const featureId = feature.getId()
const layerContent = {
resultType: SearchResultTypes.LAYER,
id: layer.id,
title: layer.name ?? '',
sanitizedTitle: sanitizeTitle(layer.name),
description: layer.description ?? '',
layerId: layer.id,
}
const locationContent = {
resultType: SearchResultTypes.LOCATION,
id: featureId,
title: featureName,
sanitizedTitle: sanitizeTitle(featureName),
description: feature.values_.description ?? '',
featureId: featureId,
coordinate: centerPoint.geometry.coordinates,
extent: normalExtent,
zoom,
}
return {
...layerContent,
...locationContent,
resultType: SearchResultTypes.FEATURE,
title: featureName,
layer,
}
}

let cancelToken = null
/**
* @param {CoordinateSystem} config.outputProjection The projection in which the search results must
Expand Down Expand Up @@ -327,6 +468,8 @@ export default async function search(config) {
)
}

allRequests.push(searchLayerFeaturesKMLGPX(layersToSearch, queryString, outputProjection))

// letting all requests finish in parallel
const allResults = await Promise.all(allRequests)
cancelToken = null
Expand Down
4 changes: 2 additions & 2 deletions src/store/modules/features.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const getEditableFeatureWithId = (state, featureId) => {
)
}

function getFeatureCountForCoordinate(coordinate) {
export function getFeatureCountForCoordinate(coordinate) {
return coordinate.length === 2
? DEFAULT_FEATURE_COUNT_SINGLE_POINT
: DEFAULT_FEATURE_COUNT_RECTANGLE_SELECTION
Expand All @@ -58,7 +58,7 @@ function getFeatureCountForCoordinate(coordinate) {
* @returns {Promise<LayerFeature[]>} A promise that will contain all feature identified by the
* different requests (won't be grouped by layer)
*/
const runIdentify = (config) => {
export const runIdentify = (config) => {
const {
layers,
coordinate,
Expand Down
72 changes: 63 additions & 9 deletions src/store/modules/search.store.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import GeoJSON from 'ol/format/GeoJSON'

import getFeature from '@/api/features/features.api'
import LayerFeature from '@/api/features/LayerFeature.class'
import LayerTypes from '@/api/layers/LayerTypes.enum'
import reframe from '@/api/lv03Reframe.api'
import search, { SearchResultTypes } from '@/api/search.api'
import { isWhat3WordsString, retrieveWhat3WordsLocation } from '@/api/what3words.api'
Expand All @@ -7,7 +11,10 @@ import { STANDARD_ZOOM_LEVEL_1_25000_MAP } from '@/utils/coordinates/CoordinateS
import { LV03 } from '@/utils/coordinates/coordinateSystems'
import { reprojectAndRound } from '@/utils/coordinates/coordinateUtils'
import { flattenExtent } from '@/utils/coordinates/coordinateUtils'
import { normalizeExtent } from '@/utils/coordinates/coordinateUtils'
import CustomCoordinateSystem from '@/utils/coordinates/CustomCoordinateSystem.class'
import { parseGpx } from '@/utils/gpxUtils'
import { parseKml } from '@/utils/kmlUtils'
import log from '@/utils/logging'

const state = {
Expand Down Expand Up @@ -174,18 +181,40 @@ const actions = {

// Automatically select the feature
try {
getFeature(entry.layer, entry.featureId, rootState.position.projection, {
lang: rootState.i18n.lang,
screenWidth: rootState.ui.width,
screenHeight: rootState.ui.height,
mapExtent: flattenExtent(getters.extent),
coordinate: entry.coordinate,
}).then((feature) => {
if (entry.layer.getTopicForIdentifyAndTooltipRequests) {
getFeature(entry.layer, entry.featureId, rootState.position.projection, {
lang: rootState.i18n.lang,
screenWidth: rootState.ui.width,
screenHeight: rootState.ui.height,
mapExtent: flattenExtent(getters.extent),
coordinate: entry.coordinate,
}).then((feature) => {
dispatch('setSelectedFeatures', {
features: [feature],
dispatcher,
})
})
} else {
// For imported KML and GPX files
let features = []
if (entry.layer.type === LayerTypes.KML) {
features = parseKml(entry.layer, rootState.position.projection, [])
}
if (entry.layer.type === LayerTypes.GPX) {
features = parseGpx(
entry.layer.gpxData,
rootState.position.projection,
[]
)
}
const layerFeatures = features
.map((feature) => createLayerFeature(feature, entry.layer))
.filter((feature) => !!feature && feature.data.title === entry.title)
dispatch('setSelectedFeatures', {
features: [feature],
features: layerFeatures,
dispatcher,
})
})
}
} catch (error) {
log.error('Error getting feature:', error)
}
Expand All @@ -199,6 +228,31 @@ const actions = {
},
}

function createLayerFeature(olFeature, layer) {
if (!olFeature.getGeometry()) return null
return new LayerFeature({
layer: layer,
id: olFeature.getId(),
name:
olFeature.get('label') ??
// exception for MeteoSchweiz GeoJSONs, we use the station name instead of the ID
// some of their layers are
// - ch.meteoschweiz.messwerte-niederschlag-10min
// - ch.meteoschweiz.messwerte-lufttemperatur-10min
olFeature.get('station_name') ??
// GPX track feature don't have an ID but have a name !
olFeature.get('name') ??
olFeature.getId(),
data: {
title: olFeature.get('name'),
description: olFeature.get('description'),
},
coordinates: olFeature.getGeometry().getCoordinates(),
geometry: new GeoJSON().writeGeometryObject(olFeature.getGeometry()),
extent: normalizeExtent(olFeature.getGeometry().getExtent()),
})
}

const mutations = {
setSearchQuery: (state, { query }) => (state.query = query),
setSearchResults: (state, { results }) => (state.results = results ?? []),
Expand Down
62 changes: 62 additions & 0 deletions tests/cypress/tests-e2e/importToolFile.cy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/// <reference types="cypress" />

import proj4 from 'proj4'

import { proxifyUrl } from '@/api/file-proxy.api.js'
import { DEFAULT_PROJECTION } from '@/config/map.config'
import { WGS84 } from '@/utils/coordinates/coordinateSystems'

describe('The Import File Tool', () => {
it('Import KML file', () => {
Expand Down Expand Up @@ -196,9 +200,67 @@ describe('The Import File Tool', () => {
}
})

// Test the search for a feature in the local KML file
const expectedSecondCenterEpsg4326 = [8.117189, 46.852375] // lon/lat
const expectedCenterEpsg4326 = [9.74921, 46.707841] // lon/lat
const expectedSecondCenterDefaultProjection = proj4(
WGS84.epsg,
DEFAULT_PROJECTION.epsg,
expectedSecondCenterEpsg4326
)
const expectedCenterDefaultProjection = proj4(
WGS84.epsg,
DEFAULT_PROJECTION.epsg,
expectedCenterEpsg4326
)
const expectedLayerId = 'external-kml-file.kml'
const expectedOnlineLayerId = 'https://example.com/second-valid-kml-file.kml'
const acceptedDelta = 0.1
const checkLocation = (expected, result) => {
expect(result).to.be.an('Array')
expect(result.length).to.eq(2)
expect(result[0]).to.approximately(expected[0], acceptedDelta)
expect(result[1]).to.approximately(expected[1], acceptedDelta)
}

cy.log('Test search for a feature in the local KML file')
cy.closeMenuIfMobile()
cy.get('[data-cy="searchbar"]').paste('placemark')
cy.get('[data-cy="search-results"]').should('be.visible')
cy.get('[data-cy="search-result-entry"]').as('layerSearchResults').should('have.length', 3)
cy.get('@layerSearchResults').invoke('text').should('contain', 'Sample Placemark')
cy.get('@layerSearchResults').first().trigger('mouseenter')
cy.readStoreValue('getters.visibleLayers').should((visibleLayers) => {
const visibleIds = visibleLayers.map((layer) => layer.id)
expect(visibleIds).to.contain(expectedLayerId)
})
cy.get('@layerSearchResults').first().realClick()
// checking that the view has centered on the feature
cy.readStoreValue('state.position.center').should((center) =>
checkLocation(expectedCenterDefaultProjection, center)
)

cy.log('Test search for a feature in the online KML file')
cy.get('[data-cy="searchbar-clear"]').click()
cy.get('[data-cy="searchbar"]').paste('another sample')
cy.get('[data-cy="search-results"]').should('be.visible')
cy.get('[data-cy="search-result-entry"]').as('layerSearchResults').should('have.length', 1)
cy.get('@layerSearchResults').invoke('text').should('contain', 'Another Sample Placemark')
cy.get('@layerSearchResults').first().trigger('mouseenter')
cy.readStoreValue('getters.visibleLayers').should((visibleLayers) => {
const visibleIds = visibleLayers.map((layer) => layer.id)
expect(visibleIds).to.contain(expectedOnlineLayerId)
})
cy.get('@layerSearchResults').first().realClick()
// checking that the view has centered on the feature
cy.readStoreValue('state.position.center').should((center) =>
checkLocation(expectedSecondCenterDefaultProjection, center)
)

//---------------------------------------------------------------------
// Test the disclaimer
cy.log('Test the external layer disclaimer')
cy.openMenuIfMobile()
cy.get('[data-cy="menu-section-active-layers"]')
.children()
.find('[data-cy="menu-external-disclaimer-icon-hard-drive"]:visible')
Expand Down

0 comments on commit fd26a8e

Please sign in to comment.