import { Geocoded } from 'universal-geocoder' import { App } from '../../gogocarto' import { AppDataType, AppModes, AppStates } from '../../app.module' import { Option, ViewPort } from '../../classes/classes' import { BoundingBox } from 'universal-geocoder/src/types' declare let L: any interface CustomJQuery extends JQuery { gogoAutocomplete(options: JQueryUI.AutocompleteOptions): JQuery gogoAutocomplete(methodName: 'search', value?: string): void } interface SearchResult { type: string value: any } interface AutocompleteItem { label: string subLabel?: string type: string value: any icon?: string } export class SearchBarComponent { searchInput(): CustomJQuery { return $('.search-bar') as CustomJQuery } private locationMarker: L.Marker private locationShapes: L.GeoJSON[] = [] private locationIcon: L.DivIcon = L.divIcon({ className: 'marker-location-position' }) private currSearchText = '' initialize(): void { $.widget('custom.gogoAutocomplete', $.ui.autocomplete, { _resizeMenu: () => { // Intentionally left empty }, _renderItem: (ul, item) => { const li = $('
  • ').addClass(`search-bar-autocomplete-result-item ${item.type}`) const wrapper = $('
    ').addClass('search-bar-autocomplete-result-item-wrapper') if (item.icon) { wrapper.append(`
    `) } if ('element' === item.type) { wrapper.addClass('nested') } wrapper.append(`
    ${item.label}
    `) if (item.subLabel) { wrapper.append(`
    ${item.subLabel}
    `) } return li.append(wrapper).appendTo(ul) }, }) this.searchInput().gogoAutocomplete({ appendTo: '.autocomplete-container', classes: { 'ui-autocomplete': 'search-bar-autocomplete-results-container gogo-section-content', }, position: { at: 'left bottom+5', }, source: ({ term }, response) => { this.beforeSearch() App.search.searchTerm(term, (elementsResults, optionsResults, locationsResults) => { this.searchLoading(true) this.setAutocompleteItems(term, elementsResults, optionsResults, locationsResults, response) }) }, focus: (event) => event.preventDefault(), select: (event, ui) => { this.beforeSearch() switch (ui.item.type) { case 'search_geocoded': this.searchGeocoded(ui.item.value) break case 'geocoded': this.showGeocoded(ui.item.value) break case 'option': this.searchOption(ui.item.value) break case 'search_elements': this.searchElements(ui.item.value.term, ui.item.value.results) break case 'element': this.searchElement(ui.item.value) break } event.preventDefault() }, }) this.searchInput().keypress((e) => { // Enter pressed if (e.keyCode === 13) { this.handleSearchAction() } }) this.searchInput().click(() => this.searchInput().gogoAutocomplete('search')) $('.search-bar-icon, .search-bar-btn').click(() => this.handleSearchAction()) $('#btn-close-search-result').click(() => this.resetElementsSearchResult()) $('.search-geolocalize').tooltip() $('.search-geolocalize').click(() => this.geolocateUser()) $('#search-overlay-mobile .overlay').click(() => this.hideMobileSearchBar()) } private setAutocompleteItems( term: string, elementsResults: SearchResult[], optionsResults: SearchResult[], locationsResults: SearchResult[], response: (data: AutocompleteItem[]) => void, ): void { let items: AutocompleteItem[] = [] if (App.config.isFeatureAvailable('searchPlace') && !App.config.search.canAutocomplete) { items = [ ...items, { label: App.config.translate('geographic.location'), type: 'search_geocoded', value: term, icon: 'gogo-icon-marker-symbol', }, ] } if (locationsResults.length > 0) { items = [ ...items, ...locationsResults.slice(0, 4).map(({ type, value }: { type: string; value: Geocoded }) => ({ type, value, label: `${ value.getFormattedAddress() || value.getStreetName() || value.getSubLocality() || value.getLocality() || value.getRegion() || value.getCountry() }`, icon: 'gogo-icon-marker-symbol', })), ] } if (optionsResults.length > 0) { items = [ ...items, ...optionsResults.slice(0, 10).map(({ type, value }: { type: string; value: Option }) => ({ type, value, label: `${App.config.translate('category')} ${value.name}`, icon: value.icon || 'gogo-icon-stamp-2', })), ] } if (elementsResults.length > 0) { items.push({ label: `${App.config.translate('elements.containing')} ${term} (${ elementsResults.length })`, type: 'search_elements', value: { term, results: { data: elementsResults.map((elementResult) => elementResult.value), }, }, icon: 'gogo-icon-database', }) items = [ ...items, ...elementsResults.slice(0, 4).map(({ type, value }) => { const elementItem: AutocompleteItem = { type, value, label: `${value.name}`, } if (value.address) { let subLabel = '' if (value.address.postalCode) { subLabel += value.address.postalCode } if (value.address.addressLocality) { subLabel += (subLabel ? ' ' : '') + value.address.addressLocality } if (subLabel) { elementItem.subLabel = subLabel } } return elementItem }), ] } response(items) } private searchGeocoded(address: string): void { if (App.config.mode.autocompleteOnly) { const route = App.routerModule.generate('normal', { mode: 'carte', addressAndViewport: address }) this.searchInput().trigger('searchRoute', route) this.searchInput().trigger('searchGeocoder', { value: address }) return } App.geocoder.geocodeAddress( address, (results) => { this.showGeocoded(results[0]) }, () => { this.searchLoading(true) $('.search-no-result').show() }, ) } private showGeocodedFromArea(results: Geocoded | any[]): void { return this.showGeocoded(results, true) } private showGeocoded(results: Geocoded | any[], keepState: boolean = false): void { if (!Array.isArray(results)) { results = [results] } if (App.config.mode.autocompleteOnly) { const address = (results[0] as any).getDisplayName() // seems an error in universal gecoder, getFormatedAddress does not work const route = App.routerModule.generate('normal', { mode: 'carte', addressAndViewport: address }) this.searchInput().trigger('searchRoute', route) this.searchInput().trigger('searchGeocoder', { value: address }) return } else { this.searchLoading(true) this.resetOptionSearchResult() this.resetElementsSearchResult(false, keepState) this.hideMobileSearchBar() results.forEach((result) => { const geojson = (result as any).shape if (geojson && ['Polygon', 'MultiPolygon'].includes(geojson.type)) { this.locationShapes.push( L.geoJSON(geojson, { style: function (feature) { return { color: App.config.colors.primary, fillOpacity: 0.05, opacity: 0.7 } }, }).addTo(App.map()), ) } else { this.displaySearchResultMarkerOnMap( L.latLng(result.getCoordinates().latitude, result.getCoordinates().longitude), ) } }) App.mapComponent.fitBounds( App.geocoder.latLngBoundsFromRawBounds(this.getBoundsFromMultipleShapes(results)), true, ) } } getBoundsFromObject(object: any): BoundingBox { try { return object.getBounds() } catch { return { latitudeSW: object.bounds[0], longitudeSW: object.bounds[2], latitudeNE: object.bounds[1], longitudeNE: object.bounds[3], } } } getBoundsFromMultipleShapes(results: any[]): BoundingBox { if (results.length === 1) { const bounds = this.getBoundsFromObject(results[0]) return bounds } else { let minLat = 90 let maxLat = -90 let minLng = 180 let maxLng = -180 results.forEach((result: Geocoded) => { const bounds = this.getBoundsFromObject(result) minLat = Math.min(bounds.latitudeSW, bounds.latitudeNE, minLat) maxLat = Math.max(bounds.latitudeSW, bounds.latitudeNE, maxLat) minLng = Math.min(bounds.longitudeSW, bounds.longitudeNE, minLng) maxLng = Math.max(bounds.longitudeSW, bounds.longitudeNE, maxLng) }) const bounds = { latitudeSW: minLat, longitudeSW: minLng, latitudeNE: maxLat, longitudeNE: maxLng, } return bounds } } searchOption(option: Option): void { if (App.config.mode.autocompleteOnly) { const route = App.routerModule.generate('search_option', { name: option.name, id: option.id }) this.searchInput().trigger('searchRoute', route) this.searchInput().trigger('searchCategory', { name: option.name, id: option.id }) return } this.searchLoading(true) this.resetOptionSearchResult(false) this.resetElementsSearchResult(false) this.hideMobileSearchBar() if (App.config.menu.showOnePanePerMainOption) { // Uncheck only the pane categories option.parentCategoryIds.forEach((parentCategoryId) => { const category = App.taxonomyModule.getCategoryById(parentCategoryId.id) if (category.isRootCategory) { return } category.toggle(false, false) }) } else { // Uncheck all categories App.taxonomyModule.categories.forEach((category) => category.toggle(false, false)) // Re-check all root categories except the parent one const parentCategoryIds = option.parentCategoryIds.map((item) => item.id) App.taxonomyModule.categories .filter((category) => category.isRootCategory && !parentCategoryIds.includes(category.id)) .forEach((category) => category.toggle(true, false)) } // Expand the related categories and check the mandatory sibling categories option.parentCategoryIds.forEach((parentCategoryId) => { App.taxonomyModule.getCategoryById(parentCategoryId.id).toggleChildrenDetail(true) parentCategoryId.mandatorySiblingIds.forEach((categoryId) => App.taxonomyModule.getCategoryById(categoryId).toggle(true, false), ) }) // Expand the related options option.parentOptionIds.forEach((parentOptionId) => App.filtersComponent.setOption(parentOptionId, false, false, true), ) // Check the option App.filtersComponent.setOption(option.id, false, true, false, true) } private searchElements(term: string, searchResults, backFromHistory = false): void { if (App.config.mode.autocompleteOnly) { const route = App.routerModule.generate('search', { mode: 'carte', text: term }) this.searchInput().trigger('searchRoute', route) this.searchInput().trigger('searchElements', { value: term }) return } this.searchLoading(true) this.resetOptionSearchResult() this.hideMobileSearchBar() this.hideSearchBarAutocomplete() this.currSearchText = term App.setDataType(AppDataType.SearchResults, backFromHistory, searchResults) this.showSearchResultLabel(searchResults.data.length) App.gogoControlComponent.updatePosition() } private searchElement(element): void { if (App.config.mode.autocompleteOnly) { const route = App.routerModule.generate('show_element', { name: element.name, id: element.id }) this.searchInput().trigger('searchRoute', route) this.searchInput().trigger('searchElement', { name: element.name, id: element.id }) return } this.searchLoading(true) this.resetOptionSearchResult() this.resetSearchResult(false) this.hideMobileSearchBar() this.hideSearchBarAutocomplete() if (!element.isFullyLoaded) App.elementsJsonModule.convertJsonElements([element], true, true) // add it so it can retrieved again by it's ID App.setState(AppStates.ShowElement, { id: element.id, mapPan: true }) } // Handle forced search action by user (input key enter pressed, icon click) private handleSearchAction(): void { const searchTermRaw = this.searchInput().val() const searchTerm = String(searchTermRaw || '') this.beforeSearch() App.search.searchTerm( searchTerm, (elementsResults, _, locationsResults) => { this.searchLoading(true) if (locationsResults.length > 0) { const matchingLocations: Geocoded[] = locationsResults .map((locationResult) => locationResult.value) .filter( (location: Geocoded) => App.search.compareResult(searchTerm, location.getLocality()) || App.search.compareResult(searchTerm, location.getRegion()), ) if (matchingLocations.length > 0) { this.searchGeocoded(searchTerm) return } } if (elementsResults.length > 0) { if (elementsResults.length == 1) { this.searchElement(elementsResults[0].value) } else { this.searchElements(searchTerm, { data: elementsResults.map((elementResult) => elementResult.value), }) } return } // Fallback to geocoded search if no precise results found in locations or elements. if (App.config.isFeatureAvailable('searchPlace')) { this.searchGeocoded(searchTerm) } }, true, ) } public handleGeocodeResult(): void { this.setValue(App.geocoder.getLocationAddress()) } public geolocateUser(): void { this.beforeSearch() App.geocoder.geolocateUser( (viewport: ViewPort) => { this.resetOptionSearchResult() this.resetElementsSearchResult() this.setValue(App.config.translate('geolocalized')) this.displaySearchResultMarkerOnMap(L.latLng(viewport.toLocation())) }, () => { this.searchLoading(true) }, ) } public loadSearchElements(text: string, backFromHistory = false): void { this.setValue(text) this.currSearchText = text const route = App.config.features.searchElements.url const data = { text } if (route) { App.ajaxModule.sendRequest( route, 'get', data, (searchResult) => this.searchElements(text, searchResult, backFromHistory), () => { this.searchLoading(true) //App.geocoder.geocodeAddress(''); }, ) } else { this.searchElements( text, { data: App.search.searchInResults(text, App.elementsModule.allElements(), (element: any) => element.name) }, backFromHistory, ) } } public showMobileSearchBar(): void { $('#search-overlay-mobile').fadeIn(250) $('.search-bar-with-options-container').show() $('.search-bar').focus() App.gogoControlComponent.hide(0) } public hideMobileSearchBar(): void { $('#search-overlay-mobile').fadeOut(150) $('.search-bar-with-options-container.mobile').hide() App.gogoControlComponent.show(0) } private hideSearchBarAutocomplete() { $('.search-bar-autocomplete-results-container').hide() } lastComponentWidth = null public update(): void { // prevent updating for nothing, because if you are typing something in the search bar // while the resize happen, it loose the focus if (App.component.width() == this.lastComponentWidth) return if (App.component.width() <= 600) { const mobileSearchBar = $('.search-bar-with-options-container') if (mobileSearchBar.parent('#search-overlay-mobile').length != 1) { $('.search-bar-with-options-container') .appendTo('#search-overlay-mobile') .addClass('mobile gogo-section-content') } } else { $('.search-bar-with-options-container') .removeClass('mobile gogo-section-content') .prependTo('.directory-menu-header') .show() } this.lastComponentWidth = App.component.width() } private searchLoading(stop = false): void { if (stop) { $('.search-bar-icon').removeClass('loading') return } $('.search-bar-icon').addClass('loading') } private showSearchResultLabel($number: number): void { $('.search-result-number').text($number) $('.search-result-value').text(this.currSearchText) $('.search-results').show() $('#element-info-bar').addClass('with-search-result-header') } private hideSearchResult(): void { $('.search-results').hide() $('#element-info-bar').removeClass('with-search-result-header') App.gogoControlComponent.updatePosition() } private resetOptionSearchResult(setAllOption = true): void { App.taxonomyModule.categories.forEach((category) => category.toggle(true, false)) if (setAllOption) { App.filtersComponent.setMainOption('all') } } private resetElementsSearchResult(resetValue = true, keepState = true): void { this.resetSearchResult(resetValue) App.setMode(AppModes.Map, false, false) if (keepState) { if (App.component.isMobileScreen() && App.infoBarComponent.isDetailsVisible) { App.infoBarComponent.toggleDetails() } } else { App.setState(AppStates.Normal) } } resetSearchResult(resetValue = true): void { App.setDataType(AppDataType.All) this.hideSearchResult() this.clearLocationMarker() this.currSearchText = '' if (resetValue) { this.setValue('') App.elementListComponent.setTitle('') } } private clearLocationMarker(): void { if (this.locationMarker) { this.locationMarker.remove() } this.clearLocationShapes() } public clearLocationShapes(): void { if (this.locationShapes) { this.locationShapes.forEach((locationShape) => { locationShape.remove() }) } } public setValue($value: string): void { this.searchInput().val($value) } public getCurrSearchText(): string { return this.currSearchText } private beforeSearch(): void { $('.search-no-result').hide() this.searchLoading() } private displaySearchResultMarkerOnMap(location: L.LatLng): void { if (!App.map()) { return } this.locationMarker = new L.Marker(location, { icon: this.locationIcon, }).addTo(App.map()) } }