Source: modules/Search.js

/**
 * @module modules/Search.js
 * @name Search
 * @copyright 2023 3Liz
 * @license MPL-2.0
 */

import { Utils } from './Utils.js';

const AUTOCOMPLETE_MIN_LENGTH = 3;

/**
 * @class
 * @name Search
 */
export default class Search {

    /**
     * Create a search instance
     * @param {Map}        map        - OpenLayers map
     * @param {object}     lizmap3    - The old lizmap object
     */
    constructor(map, lizmap3) {
        // Attributes
        this._map = map;
        this._lizmap3 = lizmap3;

        // Add or remove searches!
        var configOptions = this._lizmap3.config.options;
        if (('searches' in configOptions) && (configOptions.searches.length > 0)) {
            this._addSearches();
            this._addClearHandler();
        }
        else {
            $('#nominatim-search').remove();
            $('#lizmap-search, #lizmap-search-close').remove();
        }
    }

    /**
     * PRIVATE method: bind the header clear button to reset search state
     */
    _addClearHandler() {
        const headerClear = document.getElementById('header-clear');
        if (headerClear) {
            headerClear.addEventListener('click', () => {
                this._clearSearch();
            });
        }
    }

    /**
     * Clear search input, results, and map highlight
     */
    _clearSearch() {
        document.getElementById('search-query').value = '';
        this._map.clearHighlightFeatures();
        $('#lizmap-search .items').html('');
        $('#lizmap-search, #lizmap-search-close').removeClass('open');
    }

    /**
     * Returns a debounced version of fn that fires after delay ms of inactivity
     * @param {Function} fn
     * @param {number} delay - milliseconds
     * @returns {Function}
     */
    _debounce(fn, delay) {
        let timer;
        const debounced = (...args) => {
            clearTimeout(timer);
            timer = setTimeout(() => fn(...args), delay);
        };
        debounced.cancel = () => clearTimeout(timer);
        return debounced;
    }

    /**
     * Returns an auto-search handler that clears results below min length,
     * otherwise delegates to performFn
     * @param {Function} performFn - search function to call when input is long enough
     * @returns {Function}
     */
    _buildAutoSearch(performFn) {
        return () => {
            if (document.getElementById('search-query').value.length < AUTOCOMPLETE_MIN_LENGTH) {
                document.querySelector('#lizmap-search .items').innerHTML = '';
                document.querySelectorAll('#lizmap-search, #lizmap-search-close').forEach(el => el.classList.remove('open'));
                return;
            }
            performFn();
        };
    }

    /**
     * Start the external search
     */
    _startExternalSearch() {
        if ($('#search-query').val().length != 0) {
            $('#lizmap-search .items li > a').unbind('click');
            $('#lizmap-search .items').html('<li class="start"><ul><li>' + lizDict['externalsearch.search'] + '</li></ul></li>');
            $('#lizmap-search, #lizmap-search-close').addClass('open');
        } else {
            this._lizmap3.addMessage(lizDict['externalsearch.noquery'], 'info', true).attr('id', 'lizmap-search-message');
        }
    }

    /**
     * Get the highlight regular expression
     * @returns {RegExp} The regular expression
     */
    _getHighlightRegEx() {
        // Format answers to highlight searched keywords
        var sqval = $('#search-query').val();
        var sqvals = sqval.split(' ');
        var sqvalsn = [];
        var sqrex = '(';
        for (var i in sqvals) {
            var sqi = sqvals[i].trim();
            if (sqi == '') {
                continue;
            }
            sqvalsn.push(sqi);
            if (sqi != this._lizmap3.cleanName(sqi)) {
                sqvalsn.push(this._lizmap3.cleanName(sqi));
            }
        }
        sqrex += sqvalsn.join('|');
        sqrex += ')';
        return new RegExp(sqrex, "ig");
    }

    /**
     * PRIVATE method: fire a lizmapFts search request and display results
     * @param {object} searchConfig - search configuration
     * @param {OpenLayers.Bounds} extent - WGS84 map extent for filtering
     */
    _performFtsSearch(searchConfig, extent) {
        this._startExternalSearch();

        const labrex = this._getHighlightRegEx();
        const url = new URL(searchConfig.url, location.href);
        url.searchParams.set('repository', globalThis['lizUrls'].params.repository);
        url.searchParams.set('project', globalThis['lizUrls'].params.project);
        url.searchParams.set('query', document.getElementById('search-query').value);

        Utils.fetchJSON(url.toString()).then(results => {
            var text = '';
            var count = 0;

            for (var ftsId in results) {
                var ftsLayerResult = results[ftsId];
                text += '<li><strong>' + ftsLayerResult.search_name + '</strong>';
                text += '<ul>';
                for (var i = 0, len = ftsLayerResult.features.length; i < len; i++) {
                    var ftsFeat = ftsLayerResult.features[i];
                    var ftsGeometry = OpenLayers.Geometry.fromWKT(ftsFeat.geometry);
                    if (ftsLayerResult.srid != 'EPSG:4326') {
                        ftsGeometry.transform(ftsLayerResult.srid, 'EPSG:4326');
                    }
                    var bbox = ftsGeometry.getBounds();
                    if (extent.intersectsBounds(bbox)) {
                        var lab = ftsFeat.label.replace(labrex, '<strong class="highlight">$1</strong>');
                        text += '<li><a href="#' + bbox.toBBOX() + '" data-wkt="' + ftsGeometry.toString() + '">' + lab + '</a></li>';
                        count++;
                    }
                }
                text += '</ul></li>';
            }

            if (count != 0 && text != '') {
                this._updateExternalSearch(text, searchConfig.service);
            } else {
                this._updateExternalSearch('<li><strong>' + lizDict['externalsearch.mapdata'] + '</strong><ul><li>' + lizDict['externalsearch.notfound'] + '</li></ul></li>', searchConfig.service);
            }
        });
    }

    /**
     * PRIVATE method: add lizmapFts search capability with autocomplete
     * @param {object} searchConfig - search configuration
     * @returns {boolean} search is in the user interface
     */
    _addSearch(searchConfig) {
        if (searchConfig.type == 'externalSearch') {
            return false;
        }
        if (!searchConfig.url) {
            return false;
        }

        // define max extent for searches
        var wgs84 = new OpenLayers.Projection('EPSG:4326');
        var extent = new OpenLayers.Bounds(this._lizmap3.map.maxExtent.toArray());
        extent.transform(this._map.getView().getProjection().getCode(), wgs84);

        const autoSearch = this._buildAutoSearch(() => this._performFtsSearch(searchConfig, extent));
        const debouncedAutoSearch = this._debounce(autoSearch, 100);

        document.getElementById('nominatim-search').addEventListener('submit', evt => {
            evt.preventDefault();
            // Cancel any pending debounced auto-search so it doesn't fire a
            // redundant duplicate request for the same query right after this
            // explicit submit (which could later resolve out of order and
            // overwrite the results of a subsequent search).
            debouncedAutoSearch.cancel();
            this._performFtsSearch(searchConfig, extent);
        });

        document.getElementById('search-query').addEventListener('input', debouncedAutoSearch);

        return true;
    }

    /**
     * PRIVATE method: fire an external geocoder search request and display results
     * @param {object} searchConfig - search configuration
     * @param {string|object} service - resolved service URL or Google Geocoder instance
     * @param {OpenLayers.Bounds} extent - WGS84 map extent for filtering
     */
    _performExternalSearch(searchConfig, service, extent) {
        this._startExternalSearch();

        var labrex = this._getHighlightRegEx();
        const searchQuery = document.getElementById('search-query').value;
        switch (searchConfig.service) {
            case 'nominatim': {
                const nominatimUrl = new URL(service);
                nominatimUrl.searchParams.set('query', searchQuery);
                nominatimUrl.searchParams.set('bbox', extent.toBBOX());
                Utils.fetchJSON(nominatimUrl.toString()).then(data => {
                    var text = '';
                    var count = 0;
                    for (const address of data) {
                        if (count > 9) {
                            return false;
                        }
                        if (!address.boundingbox) {
                            return true;
                        }

                        var bbox = [
                            address.boundingbox[2],
                            address.boundingbox[0],
                            address.boundingbox[3],
                            address.boundingbox[1]
                        ];
                        bbox = new OpenLayers.Bounds(bbox);
                        if (extent.intersectsBounds(bbox)) {
                            var lab = address.display_name.replace(labrex, '<strong class="highlight">$1</strong>');
                            text += `<li><a href="#${bbox.toBBOX()}" data-wkt="POINT(${address.lon} ${address.lat})">${lab}</a></li>`;
                            count++;
                        }
                    }
                    if (count == 0 || text == '') {
                        text = '<li>' + lizDict['externalsearch.notfound'] + '</li>';
                    }
                    this._updateExternalSearch('<li><strong>OpenStreetMap</strong><ul>' + text + '</ul></li>', searchConfig.service);
                });
                break;
            }
            case 'ign': {
                if (searchQuery.length < AUTOCOMPLETE_MIN_LENGTH || searchQuery.length > 200) {
                    lizMap.addMessage(lizDict['externalsearch.ignlimit'], 'warning', true);
                    break;
                }
                const ignUrl = new URL(service);
                ignUrl.searchParams.set('text', searchQuery);
                ignUrl.searchParams.set('type', 'StreetAddress');
                ignUrl.searchParams.set('maximumResponses', 10);
                ignUrl.searchParams.set('bbox', extent.toBBOX());
                Utils.fetchJSON(ignUrl.toString()).then(data => {
                    let text = '';
                    let count = 0;
                    for (const result of data.results) {
                        var lab = result.fulltext.replace(labrex, '<strong class="highlight">$1</strong>');
                        text += `
                            <li>
                                <a href="#${result.x},${result.y},${result.x},${result.y}" data-wkt="POINT(${result.x} ${result.y})">
                                    ${lab}
                                </a>
                            </li>`;
                        count++;
                    }
                    if (count == 0 || text == '') {
                        text = '<li>' + lizDict['externalsearch.notfound'] + '</li>';
                    }
                    this._updateExternalSearch('<li><strong>IGN</strong><ul>' + text + '</ul></li>', searchConfig.service);
                });
                break;
            }
            case 'google':
                service.geocode({
                    'address': searchQuery,
                    'bounds': new google.maps.LatLngBounds(
                        new google.maps.LatLng(extent.top, extent.left),
                        new google.maps.LatLng(extent.bottom, extent.right)
                    )
                }, (results, status) => {
                    if (status == google.maps.GeocoderStatus.OK) {
                        var text = '';
                        var count = 0;
                        for (const address of results) {
                            if (count > 9) {
                                return false;
                            }
                            var bbox = [];
                            if (address.geometry.viewport) {
                                bbox = [
                                    address.geometry.viewport.getSouthWest().lng(),
                                    address.geometry.viewport.getSouthWest().lat(),
                                    address.geometry.viewport.getNorthEast().lng(),
                                    address.geometry.viewport.getNorthEast().lat()
                                ];
                            } else if (address.geometry.bounds) {
                                bbox = [
                                    address.geometry.bounds.getSouthWest().lng(),
                                    address.geometry.bounds.getSouthWest().lat(),
                                    address.geometry.bounds.getNorthEast().lng(),
                                    address.geometry.bounds.getNorthEast().lat()
                                ];
                            }
                            if (bbox.length != 4) {
                                return false;
                            }
                            bbox = new OpenLayers.Bounds(bbox);
                            if (extent.intersectsBounds(bbox)) {
                                var lab = address.formatted_address.replace(labrex, '<strong class="highlight">$1</strong>');
                                var wkt = 'POINT(' + address.geometry.location.lng() + ' ' + address.geometry.location.lat() + ')';
                                text += '<li><a href="#' + bbox.toBBOX() + '" data-wkt="' + wkt + '">' + lab + '</a></li>';
                                count++;
                            }
                        }
                        if (count == 0 || text == '') {
                            text = '<li>' + lizDict['externalsearch.notfound'] + '</li>';
                        }
                        this._updateExternalSearch('<li><strong>Google</strong><ul>' + text + '</ul></li>', searchConfig.service);
                    } else {
                        this._updateExternalSearch('<li><strong>Google</strong><ul><li>' + lizDict['externalsearch.notfound'] + '</li></ul></li>', searchConfig.service);
                    }
                });
                break;
        }
    }

    /**
     * PRIVATE method: add external geocoder search capability with autocomplete
     * @param {object} searchConfig - search configuration
     * @returns {boolean} external search is in the user interface
     */
    _addExternalSearch(searchConfig) {
        if (searchConfig.type != 'externalSearch') {
            return false;
        }

        // define max extent for searches
        var wgs84 = new OpenLayers.Projection('EPSG:4326');
        var extent = new OpenLayers.Bounds(this._lizmap3.map.maxExtent.toArray());
        extent.transform(this._map.getView().getProjection().getCode(), wgs84);

        // define external search service
        var service = null;
        switch (searchConfig.service) {
            case 'nominatim':
                if ('url' in searchConfig) {
                    service = OpenLayers.Util.urlAppend(searchConfig.url
                        , new URLSearchParams(globalThis['lizUrls'].params)
                    );
                }
                break;
            case 'ign':
                service = 'https://data.geopf.fr/geocodage/completion/';
                break;
            case 'google':
                if (google && 'maps' in google && 'Geocoder' in google.maps) {
                    service = new google.maps.Geocoder();
                }
                break;
        }

        if (service == null) {
            return false;
        }

        const autoSearch = this._buildAutoSearch(() => this._performExternalSearch(searchConfig, service, extent));
        const debouncedAutoSearch = this._debounce(autoSearch, 100);

        document.getElementById('nominatim-search').addEventListener('submit', evt => {
            evt.preventDefault();
            // Cancel any pending debounced auto-search so it doesn't fire a
            // redundant duplicate request for the same query right after this
            // explicit submit (which could later resolve out of order and
            // overwrite the results of a subsequent search).
            debouncedAutoSearch.cancel();
            this._performExternalSearch(searchConfig, service, extent);
        });

        document.getElementById('search-query').addEventListener('input', debouncedAutoSearch);

        return true;
    }

    /**
     * PRIVATE method: _addSearches
     * add searches capability
     * @returns {boolean|void} searches added to the user interface
     */
    _addSearches() {
        var configOptions = this._lizmap3.config.options;
        if (!('searches' in configOptions) || (configOptions.searches.length == 0)) {
            return;
        }

        var searchOptions = configOptions.searches;
        var searchAdded = false;
        for (var i = 0, len = searchOptions.length; i < len; i++) {
            var searchOption = searchOptions[i];
            var searchAddedResult;
            if (searchOption.type == 'externalSearch') {
                searchAddedResult = this._addExternalSearch(searchOption);
            }
            else {
                searchAddedResult = this._addSearch(searchOption);
            }
            searchAdded = searchAdded || searchAddedResult;
        }
        if (!searchAdded) {
            $('#nominatim-search').remove();
            $('#lizmap-search, #lizmap-search-close').remove();
        }
        return searchAdded;
    }

    /**
     * Update external search
     * @param {string} aHTML HTML to update
     */
    _updateExternalSearch(aHTML, sourceKey = 'external') {
        if ($('#search-query').val().length != 0) {
            var wgs84 = new OpenLayers.Projection('EPSG:4326');

            $('#lizmap-search .items li > a').unbind('click');
            // Remove the "searching…" placeholder and any previous results from
            // the same source. A source can fire twice in quick succession (the
            // debounced input search plus the Enter submit); replacing its
            // section instead of appending avoids duplicated results.
            $('#lizmap-search .items li.start').remove();
            $('#lizmap-search .items').children(`li[data-search-source="${sourceKey}"]`).remove();
            const $results = $(aHTML);
            $results.attr('data-search-source', sourceKey);
            $('#lizmap-search .items').append($results);
            $('#lizmap-search, #lizmap-search-close').addClass('open');
            document.querySelectorAll('#lizmap-search .items li > a').forEach(link => {
                link.addEventListener('click', evt => {
                    evt.preventDefault();
                    const linkClicked = evt.currentTarget;
                    var bbox = linkClicked.getAttribute('href').replace('#', '');
                    bbox = OpenLayers.Bounds.fromString(bbox);
                    bbox.transform(wgs84, this._lizmap3.map.getProjectionObject());

                    var feat = new OpenLayers.Feature.Vector(bbox.toGeometry().getCentroid());
                    var geomWKT = linkClicked.dataset.wkt;
                    if (geomWKT) {
                        this._map.zoomToWkt(geomWKT, 'EPSG:4326');
                        this._map.setHighlightFeatures(geomWKT, "wkt", "EPSG:4326");
                    } else {
                        this._map.zoomToGeometryOrExtent(bbox.toArray());
                    }

                    $('#lizmap-search, #lizmap-search-close').removeClass('open');
                    // trigger event containing selected feature
                    this._lizmap3.events.triggerEvent('lizmapexternalsearchitemselected',
                        {
                            'feature': feat
                        }
                    );
                    return false;
                });
            });

            $('#lizmap-search-close button').click(() => {
                this._map.clearHighlightFeatures();
                $('#lizmap-search, #lizmap-search-close').removeClass('open');
                return false;
            });
        }
    }
}