UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

213 lines (212 loc) • 9.83 kB
// SPDX-License-Identifier: Apache-2.0 import { SEARCH_GROUP_RECENTER_MAP, SEARCH_PROVIDER_DEFAULT } from './abstractsearchclient.js'; import GeoAdminChSearchClient from './geoadminchsearchclient.js'; import GmfSearchClient from './gmfsearchclient.js'; import GirafeSingleton from '../../base/GirafeSingleton.js'; import Feature from 'ol/Feature.js'; import { createBufferedExtentFromFeature, parseCoordinates } from '../geometrytools.js'; import { transform, transformExtent } from 'ol/proj.js'; import { Point } from 'ol/geom.js'; class SearchManager extends GirafeSingleton { clients = new Map(); abortController = new AbortController(); defaultSrid; defaultMaxExtent; minSearchTermLength = 3; constructor(context) { super(context); } async initializeSingleton() { await super.initializeSingleton(); for (const providerConfig of this.context.configManager.Config.search?.providers ?? []) { this.createClient(providerConfig); } this.defaultSrid = this.context.configManager.getDefaultConfigValue('map.srid'); this.defaultMaxExtent = this.context.configManager.Config.map.maxExtent?.split(',').map(Number); } createClient(providerConfig) { switch (providerConfig.type) { case 'geomapfish': this.clients.set(providerConfig.type, new GmfSearchClient(this.context, providerConfig)); break; case 'geoadminch': this.clients.set(providerConfig.type, new GeoAdminChSearchClient(this.context, providerConfig)); break; default: console.error(`Unknown search type: ${providerConfig.type}`); } } /** * Returns available search categories per provider. */ async getSearchCategories() { const categoriesByProvider = {}; await Promise.all(Array.from(this.clients.values()).map(async (client) => { categoriesByProvider[client.providerName] = await client.getSearchCategories(); })); // If only no provider or no categories are available, return null instead if (Object.keys(categoriesByProvider).length === 0 || Object.values(categoriesByProvider).every((categories) => categories.length === 0)) return null; return categoriesByProvider; } hasFilterShortcutsEnabled() { const searchConfig = this.context.configManager.Config.search; return (!!searchConfig?.filterEnabled && !!searchConfig?.filterShortcuts && !!searchConfig?.filterShortcutPrefix && !this.context.stateManager.state.interface.isMobile); } /** * Detects whether a search filter shortcut is present in the provided search term. * Updates the search state with the extracted prefix and shortcut if applicable. */ detectSearchFilterShortcut(term) { if (!this.hasFilterShortcutsEnabled()) { return false; } term = term.trimStart(); const prefix = this.extractFilterPrefixFromTerm(term); this.context.stateManager.state.search.filterShortcutPrefix = prefix; if (prefix) { this.context.stateManager.state.search.filterShortcut = this.extractFilterShortcutFromTerm(term, prefix); return true; } return false; } extractFilterPrefixFromTerm(term) { return (this.context.configManager.Config.search.filterShortcutPrefix.find((prefix) => term.startsWith(prefix)) ?? null); } extractFilterShortcutFromTerm(term, prefix) { const availableShortcuts = this.context.configManager.Config.search?.filterShortcuts ?? {}; const shortcutCandidateWithoutPrefix = term.substring(prefix.length).split(' ')[0]; const categoriesToActivate = availableShortcuts[shortcutCandidateWithoutPrefix] ?? []; if (categoriesToActivate.length) { // A valid shortcut was detected if (term.startsWith(`${prefix}${shortcutCandidateWithoutPrefix} `)) { // Only when a space is added after the finished shortcut, the search filter is applied return shortcutCandidateWithoutPrefix; } } return null; } abortSearch() { this.abortController.abort(); } /** * Performs a search operation with all available clients based on the provided term and optional search filters. * Returns results grouped by provider name and result category. */ async doSearch(term, searchFilter = null) { // Cancel any previous search this.abortSearch(); // Create a new controller for the new request this.abortController = new AbortController(); let searchResults = null; term = term.trim(); if (!searchFilter) { const coordinates = this.detectCoordinatesInTerm(term); if (coordinates) { searchResults = this.createCoordinateSearchResult(coordinates); } } if (!searchResults && term.length >= this.minSearchTermLength) { const responsesArray = await Promise.all(Array.from(this.clients.values()).map(async (client) => { const response = await client.requestSearch(term, searchFilter ? (searchFilter[client.providerName] ?? []) : [], this.abortController); return { [client.providerName]: client.groupedResults(response) }; })); // Merge the responses of multiple clients into a single object that has the client name as key // and the grouped results as values searchResults = responsesArray.reduce((acc, curr) => ({ ...acc, ...curr }), {}); } return searchResults; } createCoordinateSearchResult(coordinates) { const currentSrid = this.context.mapManager.getMap().getView().getProjection().getCode(); let parsedCoordinates; // Checks if the coordinates are within the default SRID max extent or geographic parsedCoordinates = parseCoordinates(coordinates, this.defaultMaxExtent, this.defaultSrid); if (this.isCoordinatePairValid(parsedCoordinates) && this.defaultSrid !== currentSrid) { parsedCoordinates = transform(parsedCoordinates, this.defaultSrid, currentSrid); parsedCoordinates = parsedCoordinates.map((number) => Math.round(number * 100) / 100); } // Try to parse the coordinates in the current map projection if (!this.isCoordinatePairValid(parsedCoordinates) && this.defaultMaxExtent) { const currentMaxExtent = transformExtent(this.defaultMaxExtent, this.defaultSrid, currentSrid); parsedCoordinates = parseCoordinates(coordinates, currentMaxExtent, currentSrid); } if (!this.isCoordinatePairValid(parsedCoordinates)) { return null; } const resultFeature = new Feature({ geometry: new Point(parsedCoordinates), label: `${parsedCoordinates[0]} ${parsedCoordinates[1]}` }); // Return this result grouped by the default provider and as part of the `recenter_map` group return { [SEARCH_PROVIDER_DEFAULT]: { [SEARCH_GROUP_RECENTER_MAP]: [resultFeature] } }; } /** * Detects and parses a coordinate search term into an array of two numerical values. * The method attempts to identify valid coordinate pairs from the given term, * using different separators such as `;`, `/`, `,`, or whitespace. */ detectCoordinatesInTerm(term) { term = term.trim(); if (term.length < 3) { return null; } let parts = []; // Supported separators: const separators = [ { char: ';' }, { char: '/' }, // Only search for one comma, because a comma could also be used as a decimal separator { char: ',', condition: () => [...term].filter((c) => c === ',').length === 1 }, // Whitespace separator { char: null, regex: /\s+/ } ]; // Search for the coordinate separator step-wise for (const { char, regex, condition } of separators) { if (char && (condition ? condition() : term.includes(char))) { parts = term.split(char); break; } else if (regex?.test(term)) { parts = term.split(regex); break; } } if (parts.length !== 2) { return null; } const first = this.parseCoordinateFromString(parts[0]); const second = this.parseCoordinateFromString(parts[1]); return first === null || second === null ? null : [first, second]; } parseCoordinateFromString(value) { const normalized = value // Remove coordinate prefix and thousand separator .replace(/[NESW']/g, '') // Replace decimal separator .replace(',', '.') .trim(); const number = Number(normalized); return Number.isFinite(number) ? number : null; } isCoordinatePairValid(coordinates) { return !!coordinates && coordinates.length === 2 && coordinates.every((coordinate) => Number.isFinite(coordinate)); } getZoomToBbox(feature) { const providerName = feature.get('provider'); if (this.clients.has(providerName)) { return this.clients.get(providerName)?.getZoomToBbox(feature); } else { return createBufferedExtentFromFeature(feature); } } getI18nKeyForCategory(providerName, category) { return this.clients.get(providerName)?.getI18nKeyForCategory(category) ?? category; } } export default SearchManager;