commit bd8d8306a323bd018dd59fb43e92303f156523fb
parent 7da0fe7ad05b50c11085e7b9af65004046a28659
Author: Sergey Orlov <wladimirych@gmail.com>
Date: Tue, 14 Jul 2020 09:42:40 +0200
Add search control
* Two providers - mapy.cz and photon
* parse coordinates links to other services
* save query to hash state
* hotkey for focus
* place track waypoints at search result position
Diffstat:
24 files changed, 2776 insertions(+), 1 deletion(-)
diff --git a/.eslintrc b/.eslintrc
@@ -131,6 +131,9 @@
},
"globals": {
"assert": true
+ },
+ "rules": {
+ "max-nested-callbacks": ["error", 5]
}
},
{
diff --git a/package.json b/package.json
@@ -34,6 +34,7 @@
"cross-env": "^7.0.2",
"css-loader": "^3.6.0",
"cssnano": "^4.1.10",
+ "csv-loader": "^3.0.3",
"eslint": "^7.4.0",
"eslint-config-prettier": "^6.11.0",
"eslint-loader": "^4.0.2",
diff --git a/src/App.js b/src/App.js
@@ -33,6 +33,8 @@ import ZoomDisplay from '~/lib/leaflet.control.zoom-display';
import logging from '~/lib/logging';
import safeLocalStorage from '~/lib/safe-localstorage';
import {ExternalMaps} from '~/lib/leaflet.control.external-maps/index.js';
+import {SearchControl} from '~/lib/leaflet.control.search';
+import '~/lib/leaflet.placemark';
const locationErrorMessage = {
0: 'Your browser does not support geolocation.',
@@ -69,6 +71,11 @@ function setUp() {
new ZoomDisplay().addTo(map);
+ const searchControl = new SearchControl({position: 'topleft', stackHorizontally: true, maxMapWidthToMinimize: 620})
+ .addTo(map)
+ .enableHashState('q');
+ map.getPlacemarkHashStateInterface().enableHashState('r');
+
new L.Control.Scale({
imperial: false,
position: 'topleft',
@@ -296,6 +303,25 @@ function setUp() {
});
});
+ searchControl.on('resultreceived', function(e) {
+ logging.logEvent('SearchProviderSelected', {
+ provider: e.provider,
+ query: e.query,
+ });
+ if (e.provider === 'Links' && e.result.error) {
+ logging.logEvent('SearchLinkError', {
+ query: e.query,
+ result: e.result,
+ });
+ }
+ if (e.provider === 'Coordinates') {
+ logging.logEvent('SearchCoordinates', {
+ query: e.query,
+ result: e.result,
+ });
+ }
+ });
+
logging.logEvent('start', startInfo);
logUsedMaps();
}
diff --git a/src/lib/leaflet.control.search/button.svg b/src/lib/leaflet.control.search/button.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
+ viewBox="0 0 512.005 512.005" style="enable-background:new 0 0 512.005 512.005;" xml:space="preserve">
+ <g style="fill: #333">
+ <path d="M505.749,475.587l-145.6-145.6c28.203-34.837,45.184-79.104,45.184-127.317c0-111.744-90.923-202.667-202.667-202.667
+ S0,90.925,0,202.669s90.923,202.667,202.667,202.667c48.213,0,92.48-16.981,127.317-45.184l145.6,145.6
+ c4.16,4.16,9.621,6.251,15.083,6.251s10.923-2.091,15.083-6.251C514.091,497.411,514.091,483.928,505.749,475.587z
+ M202.667,362.669c-88.235,0-160-71.765-160-160s71.765-160,160-160s160,71.765,160,160S290.901,362.669,202.667,362.669z"/>
+ </g>
+</svg>
diff --git a/src/lib/leaflet.control.search/clear.svg b/src/lib/leaflet.control.search/clear.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 357 357" style="enable-background:new 0 0 357 357;" xml:space="preserve">
+ <g id="close">
+ <polygon points="357,35.7 321.3,0 178.5,142.8 35.7,0 0,35.7 142.8,178.5 0,321.3 35.7,357 178.5,214.2 321.3,357 357,321.3 214.2,178.5"
+ fill="#999"/>
+ </g>
+</svg>
diff --git a/src/lib/leaflet.control.search/control.html b/src/lib/leaflet.control.search/control.html
@@ -0,0 +1,59 @@
+<!-- ko if: minimizeToButton -->
+<div class="leaflet-search-button leaflet-bar leaflet-control-single-button"
+ data-bind="attr: {title: inputPlaceholder}">
+ <a href="#" data-bind="click: onButtonClick">
+ <div class="icon-search"></div>
+ </a>
+</div>
+<!-- /ko -->
+<!-- ko ifnot: minimizeToButton -->
+<div class="leaflet-search-placeholder"></div>
+<div class="leaflet-search"
+ tabindex="-1"
+ data-bind="
+ css: {hasresults: showResults() || showError() || showWarningTooShort()},
+ hasFocusNested: controlOrChildHasFocus,
+ event: {keydown: onKeyDown}">
+ <div class="leaflet-search-input-wrapper">
+ <input
+ tabindex="-1"
+ type="search"
+ maxlength="4096"
+ class="leaflet-search-input"
+ data-bind="
+ textInput: query,
+ event: {
+ contextmenu: defaultEventHandle,
+ mousemove: defaultEventHandle
+ },
+ hasFocus: inputHasFocus,
+ attr: {
+ placeholder: inputPlaceholder,
+ title: helpText,
+ },
+ ">
+ <div class="leaflet-search-clear-button" data-bind="visible: query, click: onClearClick"></div>
+ </div>
+ <ul class="leaflet-search-results" data-bind="visible: showResults">
+ <!-- ko foreach: {data: items} -->
+ <li data-bind="
+ event: {mouseover: $root.onItemMouseOver},
+ click: $root.onItemClick,
+ css: {highlighted: $index() === $root.highlightedIndex()}">
+ <p><span class="title" data-bind="text: title"></span> <img class="icon" data-bind="visible: icon, attr: {src: icon}"><span class="category" data-bind="text: category"></span></p>
+ <p class="address" data-bind="text: address"></p>
+ </li>
+ <!-- /ko -->
+ <!-- ko if: attribution -->
+ <li class="search-provider-attribution">
+ Search powered by <a data-bind="
+ text: attribution().text,
+ attr: {href: attribution().url},
+ event: {contextmenu: defaultEventHandle}"></a>
+ </li>
+ <!-- /ko -->
+ </ul>
+ <div class="leaflet-search-error" data-bind="visible: showError, text: error"></div>
+ <div class="leaflet-search-error" data-bind="visible: showWarningTooShort">Query too short</div>
+</div>
+<!-- /ko -->
diff --git a/src/lib/leaflet.control.search/index.js b/src/lib/leaflet.control.search/index.js
@@ -0,0 +1,338 @@
+import L from 'leaflet';
+import ko from 'knockout';
+
+import '~/lib/leaflet.placemark';
+import {stopContainerEvents} from '~/lib/leaflet.control.commons';
+import '~/lib/leaflet.hashState/leaflet.hashState';
+
+import {providers, magicProviders} from './providers/index';
+import './style.css';
+import controlTemplate from './control.html';
+
+ko.bindingHandlers.hasFocusNested = {
+ init: function(element, valueAccessor) {
+ function hasFocusNested(element) {
+ let active = document.activeElement;
+ while (active) {
+ if (element === active) {
+ return true;
+ }
+ active = active.parentElement;
+ }
+ return false;
+ }
+
+ function handleFocusChange() {
+ // wait for all related focus/blur events to fire
+ setTimeout(() => {
+ valueAccessor()(hasFocusNested(element));
+ }, 0);
+ }
+ element.addEventListener('focus', handleFocusChange, true);
+ element.addEventListener('blur', handleFocusChange, true);
+ },
+};
+
+class SearchViewModel {
+ query = ko.observable('');
+ inputPlaceholder = ko.observable(null);
+ helpText = ko.observable(null);
+ items = ko.observableArray([]);
+ error = ko.observable(null);
+ inputHasFocus = ko.observable(false);
+ controlOrChildHasFocus = ko.observable(false);
+ highlightedIndex = ko.observable(null);
+ attribution = ko.observable(null);
+ allowMinimize = ko.observable(true);
+
+ controlHasFocus = ko.pureComputed(function() {
+ return this.inputHasFocus() || this.controlOrChildHasFocus();
+ }, this);
+
+ showResults = ko.pureComputed(function() {
+ return this.items().length > 0 && this.controlHasFocus();
+ }, this);
+
+ showError = ko.pureComputed(function() {
+ return this.error() !== null && this.controlHasFocus();
+ }, this);
+
+ isQueryLengthOk = ko.computed(function() {
+ return this.query().trim().length >= this.minSearchQueryLength;
+ }, this);
+
+ showWarningTooShort = ko.pureComputed(function() {
+ return this.controlHasFocus() && this.query() && !this.isQueryLengthOk();
+ }, this);
+
+ minimizeToButton = ko.pureComputed(function() {
+ return this.allowMinimize() && !this.controlHasFocus();
+ }, this);
+
+ onItemMouseOver = (item) => {
+ this.highlightedIndex(this.items.indexOf(item));
+ };
+
+ onItemClick = (item) => {
+ this.itemSelected(item);
+ };
+
+ onButtonClick = (_, e) => {
+ L.DomEvent.preventDefault(e);
+ this.inputHasFocus(true);
+ };
+
+ onShowResults(show) {
+ if (show) {
+ this.highlightedIndex(0);
+ }
+ }
+
+ onControlHasFocusChange(active) {
+ if (active) {
+ this.maybeRequestSearch(this.query());
+ } else {
+ this.items.removeAll();
+ this.error(null);
+ }
+ }
+
+ onClearClick() {
+ this.query('');
+ this.inputHasFocus(true);
+ }
+
+ defaultEventHandle(_, e) {
+ L.DomEvent.stopPropagation(e);
+ return true;
+ }
+
+ onQueryChange() {
+ this.items.removeAll();
+ this.error(null);
+ this.searchAborted(null);
+ this.maybeRequestSearch();
+ }
+
+ onKeyDown = (_, e) => {
+ let n;
+ switch (e.which) {
+ case 38: // up
+ n = this.highlightedIndex();
+ if (n === null) {
+ n = this.items().length - 1;
+ } else {
+ n -= 1;
+ }
+ if (n === -1) {
+ n = this.items().length - 1;
+ }
+ this.highlightedIndex(n);
+ break;
+ case 40: // down
+ n = this.highlightedIndex();
+ if (n === null) {
+ n = 0;
+ } else {
+ n += 1;
+ }
+ if (n === this.items().length) {
+ n = 0;
+ }
+ this.highlightedIndex(n);
+ break;
+ case 13: // enter
+ if (this.items().length > 0) {
+ this.itemSelected(this.items()[this.highlightedIndex()]);
+ }
+ break;
+ case 27: // esc
+ this.escapePressed(null);
+ break;
+ default:
+ return true;
+ }
+ return false;
+ };
+
+ maybeRequestSearch() {
+ if (this.isQueryLengthOk() && this.controlHasFocus()) {
+ this.searchRequested(null);
+ }
+ }
+
+ // public events
+ itemSelected = ko.observable().extend({notify: 'always'});
+ searchRequested = ko.observable().extend({notify: 'always'});
+ searchAborted = ko.observable().extend({notify: 'always'});
+ escapePressed = ko.observable().extend({notify: 'always'});
+
+ // public methods
+ setResult(items, attribution) {
+ this.items.splice(0, this.items().length, ...items);
+ this.error(null);
+ this.attribution(attribution);
+ }
+
+ setResultError(error) {
+ this.items.removeAll();
+ this.error(error);
+ }
+
+ setFocus() {
+ this.inputHasFocus(true);
+ }
+
+ setInputPlaceholder(s) {
+ this.inputPlaceholder(s);
+ }
+
+ setHelpText(s) {
+ this.helpText(s);
+ }
+
+ setMinimizeAllowed(allowed) {
+ this.allowMinimize(allowed);
+ }
+
+ constructor(minSearchQueryLength) {
+ this.minSearchQueryLength = minSearchQueryLength;
+ this.query.subscribe(this.onQueryChange.bind(this));
+ this.showResults.subscribe(this.onShowResults.bind(this));
+ this.controlHasFocus.subscribe(this.onControlHasFocusChange.bind(this));
+ }
+}
+
+const SearchControl = L.Control.extend({
+ includes: L.Mixin.Events,
+
+ options: {
+ provider: 'mapycz',
+ providerOptions: {
+ maxResponses: 5,
+ },
+ minQueryLength: 3,
+ hotkey: 'L',
+ maxMapHeightToMinimize: 567,
+ maxMapWidthToMinimize: 450,
+ tooltip: 'Search places, coordinates, links (Alt-{hotkey})',
+ help: 'Coordinates in any format. Links to maps: Yandex, Google, OSM, Mapy.cz, Nakarte',
+ },
+
+ initialize: function(options) {
+ L.Control.prototype.initialize.call(this, options);
+ this.provider = new providers[this.options.provider](this.options.providerOptions);
+ this.magicProviders = magicProviders.map((Cls) => new Cls());
+ this.searchPromise = null;
+ this.viewModel = new SearchViewModel(this.options.minQueryLength);
+ this.viewModel.setInputPlaceholder(L.Util.template(this.options.tooltip, this.options));
+ this.viewModel.setHelpText(this.options.help);
+ this.viewModel.searchRequested.subscribe(this.onSearchRequested.bind(this));
+ this.viewModel.searchAborted.subscribe(this.onSearchAborted.bind(this));
+ this.viewModel.itemSelected.subscribe(this.onResultItemClicked.bind(this));
+ this.viewModel.query.subscribe(() => this.fire('querychange'));
+ this.viewModel.escapePressed.subscribe(this.setFocusToMap.bind(this));
+ },
+
+ onAdd: function(map) {
+ this._map = map;
+ const container = L.DomUtil.create('div', 'leaflet-search-container');
+ container.innerHTML = controlTemplate;
+ stopContainerEvents(container);
+ ko.applyBindings(this.viewModel, container);
+
+ this.searchPromise = null;
+
+ L.DomEvent.on(document, 'keyup', this.onDocumentKeyUp, this);
+ map.on('resize', this.updateMinimizeAllowed, this);
+ this.updateMinimizeAllowed();
+
+ // enable setting focus to map container
+ const mapContainer = map.getContainer();
+ if (mapContainer.tabIndex === undefined) {
+ mapContainer.tabIndex = -1;
+ }
+ return container;
+ },
+
+ setFocusToMap: function() {
+ this._map.getContainer().focus();
+ },
+
+ onSearchRequested: async function() {
+ const query = this.viewModel.query();
+ const searchOptions = {
+ bbox: this._map.getBounds(),
+ latlng: this._map.getCenter(),
+ zoom: this._map.getZoom(),
+ };
+ let provider = this.provider;
+ for (let magicProvider of this.magicProviders) {
+ if (magicProvider.isOurQuery(query)) {
+ provider = magicProvider;
+ }
+ }
+ const searchPromise = (this.searchPromise = provider.search(query, searchOptions));
+ const result = await searchPromise;
+ this.fire('resultreceived', {provider: provider.name, query, result});
+ if (this.searchPromise === searchPromise) {
+ if (result.error) {
+ this.viewModel.setResultError(result.error);
+ } else if (result.results.length === 0) {
+ this.viewModel.setResultError('Nothing found');
+ } else {
+ this.viewModel.setResult(result.results, provider.attribution);
+ }
+ }
+ },
+
+ onSearchAborted: function() {
+ this.searchPromise = null;
+ },
+
+ onResultItemClicked: function(item) {
+ if (item.bbox) {
+ this._map.fitBounds(item.bbox);
+ } else {
+ this._map.setView(item.latlng, item.zoom);
+ }
+ this._map.showPlacemark(item.latlng, item.title);
+ this.setFocusToMap();
+ },
+
+ onDocumentKeyUp: function(e) {
+ if (e.keyCode === this.options.hotkey.codePointAt(0) && e.altKey) {
+ this.viewModel.setFocus();
+ }
+ },
+
+ updateMinimizeAllowed: function() {
+ const mapSize = this._map.getSize();
+ this.viewModel.setMinimizeAllowed(
+ mapSize.y < this.options.maxMapHeightToMinimize || mapSize.x < this.options.maxMapWidthToMinimize
+ );
+ },
+});
+
+SearchControl.include(L.Mixin.HashState);
+SearchControl.include({
+ stateChangeEvents: ['querychange'],
+
+ serializeState: function() {
+ const query = this.viewModel.query();
+ if (query) {
+ return [encodeURIComponent(query)];
+ }
+ return null;
+ },
+
+ unserializeState: function(state) {
+ if (state?.length === 1) {
+ this.viewModel.query(decodeURIComponent(state[0]));
+ return true;
+ }
+ return false;
+ },
+});
+
+export {SearchControl};
diff --git a/src/lib/leaflet.control.search/providers/coordinates.js b/src/lib/leaflet.control.search/providers/coordinates.js
@@ -0,0 +1,449 @@
+import L from 'leaflet';
+
+const
+ reInteger = '\\d+',
+ reFractional = '\\d+(?:\\.\\d+)?',
+ reSignedFractional = '-?\\d+(?:\\.\\d+)?',
+ reHemisphere = '[NWSE]';
+
+class Coordinates {
+ getLatitudeLetter() {
+ return this.latIsSouth ? 'S' : 'N';
+ }
+
+ getLongitudeLetter() {
+ return this.lonIsWest ? 'W' : 'E';
+ }
+
+ static parseHemispheres(h1, h2, h3, allowEmpty = false) {
+ function isLat(h) {
+ return h === 'N' || h === 'S';
+ }
+ let swapLatLon = false;
+ let hLat, hLon;
+ if (h1 && h2 && !h3) {
+ hLat = h1.trim();
+ hLon = h2.trim();
+ } else if (h1 && !h2 && h3) {
+ hLat = h1.trim();
+ hLon = h3.trim();
+ } else if (!h1 && h2 && h3) {
+ hLat = h2.trim();
+ hLon = h3.trim();
+ } else if (allowEmpty && !h1 && !h2 && !h3) {
+ return {empty: true};
+ } else {
+ return {error: true};
+ }
+ if (isLat(hLat) === isLat(hLon)) {
+ return {error: true};
+ }
+ if (isLat(hLon)) {
+ [hLat, hLon] = [hLon, hLat];
+ swapLatLon = true;
+ }
+ const latIsSouth = hLat === 'S';
+ const lonIsWest = hLon === 'W';
+ return {swapLatLon, latIsSouth, lonIsWest};
+ }
+}
+
+class CoordinatesDMS extends Coordinates {
+ static regexp = new RegExp(
+ // eslint-disable-next-line max-len
+ `^(${reHemisphere} )?(${reInteger}) (${reInteger}) (${reFractional}) (${reHemisphere} )?(${reInteger}) (${reInteger}) (${reFractional})( ${reHemisphere})?$`,
+ 'u'
+ );
+
+ constructor(latDeg, latMin, latSec, latIsSouth, lonDeg, lonMin, lonSec, lonIsWest) {
+ super();
+ Object.assign(this, {latDeg, latMin, latSec, latIsSouth, lonDeg, lonMin, lonSec, lonIsWest});
+ }
+
+ equalTo(other) {
+ return (
+ this.latDeg === other.latDeg &&
+ this.latMin === other.latMin &&
+ this.latSec === other.latSec &&
+ this.latIsSouth === other.latIsSouth &&
+ this.lonDeg === other.lonDeg &&
+ this.lonMin === other.lonMin &&
+ this.lonSec === other.lonSec &&
+ this.lonIsWest === other.lonIsWest
+ );
+ }
+
+ format() {
+ return {
+ latitude: `${this.getLatitudeLetter()} ${this.latDeg}°${this.latMin}′${this.latSec}″`,
+ longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°${this.lonMin}′${this.lonSec}″`,
+ };
+ }
+
+ isValid() {
+ return (
+ this.latDeg >= 0 &&
+ this.latDeg <= 90 &&
+ this.latMin >= 0 &&
+ this.latMin <= 59 &&
+ this.latSec >= 0 &&
+ this.latSec < 60 &&
+ this.lonDeg >= 0 &&
+ this.lonDeg <= 180 &&
+ this.lonMin >= 0 &&
+ this.lonMin <= 59 &&
+ this.lonSec >= 0 &&
+ this.lonSec < 60 &&
+ (this.latDeg <= 89 || (this.latMin === 0 && this.latSec === 0)) &&
+ (this.lonDeg <= 179 || (this.lonMin === 0 && this.lonSec === 0))
+ );
+ }
+
+ getLatLng() {
+ let lat = this.latDeg + this.latMin / 60 + this.latSec / 3600;
+ if (this.latIsSouth) {
+ lat = -lat;
+ }
+ let lon = this.lonDeg + this.lonMin / 60 + this.lonSec / 3600;
+ if (this.lonIsWest) {
+ lon = -lon;
+ }
+ return L.latLng(lat, lon);
+ }
+
+ static fromString(s) {
+ const m = s.match(CoordinatesDMS.regexp);
+ if (!m) {
+ return {error: true};
+ }
+ let [h1, d1, m1, s1, h2, d2, m2, s2, h3] = m.slice(1);
+ const hemispheres = CoordinatesDMS.parseHemispheres(h1, h2, h3, true);
+ if (hemispheres.error) {
+ return {error: true};
+ }
+ [d1, m1, s1, d2, m2, s2] = [d1, m1, s1, d2, m2, s2].map(parseFloat);
+ const coords = [];
+ if (hemispheres.empty) {
+ const coord1 = new CoordinatesDMS(d1, m1, s1, false, d2, m2, s2, false);
+ const coord2 = new CoordinatesDMS(d2, m2, s2, false, d1, m1, s1, false);
+ if (coord1.isValid()) {
+ coords.push(coord1);
+ }
+ if (!coord1.equalTo(coord2) && coord2.isValid()) {
+ coords.push(coord2);
+ }
+ } else {
+ if (hemispheres.swapLatLon) {
+ [d1, m1, s1, d2, m2, s2] = [d2, m2, s2, d1, m1, s1];
+ }
+ const coord = new CoordinatesDMS(d1, m1, s1, hemispheres.latIsSouth, d2, m2, s2, hemispheres.lonIsWest);
+ if (coord.isValid()) {
+ coords.push(coord);
+ }
+ }
+ if (coords.length > 0) {
+ return {coordinates: coords};
+ }
+ return {error: true};
+ }
+}
+
+class CoordinatesDM extends Coordinates {
+ static regexp = new RegExp(
+ `^(${reHemisphere} )?(${reInteger}) (${reFractional}) (${reHemisphere} )?(${reInteger}) (${reFractional})( ${reHemisphere})?$`, // eslint-disable-line max-len
+ 'u'
+ );
+
+ constructor(latDeg, latMin, latIsSouth, lonDeg, lonMin, lonIsWest) {
+ super();
+ Object.assign(this, {latDeg, latMin, latIsSouth, lonDeg, lonMin, lonIsWest});
+ }
+
+ equalTo(other) {
+ return (
+ this.latDeg === other.latDeg &&
+ this.latMin === other.latMin &&
+ this.latIsSouth === other.latIsSouth &&
+ this.lonDeg === other.lonDeg &&
+ this.lonMin === other.lonMin &&
+ this.lonIsWest === other.lonIsWest
+ );
+ }
+
+ format() {
+ return {
+ latitude: `${this.getLatitudeLetter()} ${this.latDeg}°${this.latMin}′`,
+ longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°${this.lonMin}′`,
+ };
+ }
+
+ isValid() {
+ return (
+ this.latDeg >= 0 &&
+ this.latDeg <= 90 &&
+ this.latMin >= 0 &&
+ this.latMin < 60 &&
+ this.lonDeg >= 0 &&
+ this.lonDeg <= 180 &&
+ this.lonMin >= 0 &&
+ this.lonMin < 60 &&
+ (this.latDeg <= 89 || this.latMin === 0) &&
+ (this.lonDeg <= 179 || this.lonMin === 0)
+ );
+ }
+
+ getLatLng() {
+ let lat = this.latDeg + this.latMin / 60;
+ if (this.latIsSouth) {
+ lat = -lat;
+ }
+ let lon = this.lonDeg + this.lonMin / 60;
+ if (this.lonIsWest) {
+ lon = -lon;
+ }
+ return L.latLng(lat, lon);
+ }
+
+ static fromString(s) {
+ const m = s.match(CoordinatesDM.regexp);
+ if (!m) {
+ return {error: true};
+ }
+ let [h1, d1, m1, h2, d2, m2, h3] = m.slice(1);
+ const hemispheres = CoordinatesDM.parseHemispheres(h1, h2, h3, true);
+ if (hemispheres.error) {
+ return {error: true};
+ }
+ [d1, m1, d2, m2] = [d1, m1, d2, m2].map(parseFloat);
+ const coords = [];
+ if (hemispheres.empty) {
+ const coord1 = new CoordinatesDM(d1, m1, false, d2, m2, false);
+ const coord2 = new CoordinatesDM(d2, m2, false, d1, m1, false);
+ if (coord1.isValid()) {
+ coords.push(coord1);
+ }
+ if (!coord1.equalTo(coord2) && coord2.isValid()) {
+ coords.push(coord2);
+ }
+ } else {
+ if (hemispheres.swapLatLon) {
+ [d1, m1, d2, m2] = [d2, m2, d1, m1];
+ }
+ const coord = new CoordinatesDM(d1, m1, hemispheres.latIsSouth, d2, m2, hemispheres.lonIsWest);
+ if (coord.isValid()) {
+ coords.push(coord);
+ }
+ }
+ if (coords.length > 0) {
+ return {coordinates: coords};
+ }
+ return {error: true};
+ }
+}
+
+class CoordinatesD extends Coordinates {
+ static regexp = new RegExp(
+ `^(${reHemisphere} )?(${reFractional}) (${reHemisphere} )?(${reFractional})( ${reHemisphere})?$`,
+ 'u'
+ );
+
+ constructor(latDeg, latIsSouth, lonDeg, lonIsWest) {
+ super();
+ Object.assign(this, {latDeg, latIsSouth, lonDeg, lonIsWest});
+ }
+
+ format() {
+ return {
+ latitude: `${this.getLatitudeLetter()} ${this.latDeg}°`,
+ longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°`,
+ };
+ }
+
+ equalTo(other) {
+ return (
+ this.latDeg === other.latDeg &&
+ this.latIsSouth === other.latIsSouth &&
+ this.lonDeg === other.lonDeg &&
+ this.lonIsWest === other.lonIsWest
+ );
+ }
+
+ isValid() {
+ return this.latDeg >= 0 && this.latDeg <= 90 && this.lonDeg >= 0 && this.lonDeg <= 180;
+ }
+
+ getLatLng() {
+ let lat = this.latDeg;
+ if (this.latIsSouth) {
+ lat = -lat;
+ }
+ let lon = this.lonDeg;
+ if (this.lonIsWest) {
+ lon = -lon;
+ }
+ return L.latLng(lat, lon);
+ }
+
+ static fromString(s) {
+ const m = s.match(CoordinatesD.regexp);
+ if (!m) {
+ return {error: true};
+ }
+ let [h1, d1, h2, d2, h3] = m.slice(1);
+ const hemispheres = CoordinatesD.parseHemispheres(h1, h2, h3);
+ if (hemispheres.error) {
+ return {error: true};
+ }
+ if (hemispheres.swapLatLon) {
+ [d1, d2] = [d2, d1];
+ }
+ [d1, d2] = [d1, d2].map(parseFloat);
+ const coord = new CoordinatesD(d1, hemispheres.latIsSouth, d2, hemispheres.lonIsWest);
+ if (coord.isValid()) {
+ return {
+ coordinates: [coord],
+ };
+ }
+ return {error: true};
+ }
+}
+
+class CoordinatesDSigned extends Coordinates {
+ static regexp = new RegExp(`^(${reSignedFractional}) (${reSignedFractional})$`, 'u');
+
+ constructor(latDegSigned, lonDegSigned) {
+ super();
+ Object.assign(this, {latDegSigned, lonDegSigned});
+ }
+
+ equalTo(other) {
+ return this.latDegSigned === other.latDegSigned && this.lonDegSigned === other.lonDegSigned;
+ }
+
+ isValid() {
+ return (
+ this.latDegSigned >= -90 && this.latDegSigned <= 90 && this.lonDegSigned >= -180 && this.lonDegSigned <= 180
+ );
+ }
+
+ format() {
+ return {
+ latitude: `${this.latDegSigned}°`,
+ longitude: `${this.lonDegSigned}°`,
+ };
+ }
+
+ getLatLng() {
+ return L.latLng(this.latDegSigned, this.lonDegSigned);
+ }
+
+ static fromString(s) {
+ const m = s.match(CoordinatesDSigned.regexp);
+ if (!m) {
+ return {error: true};
+ }
+ const coords = [];
+ let [d1, d2] = m.slice(1).map(parseFloat);
+ const coord1 = new CoordinatesDSigned(d1, d2);
+ if (coord1.isValid()) {
+ coords.push(coord1);
+ }
+ const coord2 = new CoordinatesDSigned(d2, d1);
+ if (!coord1.equalTo(coord2)) {
+ if (coord2.isValid()) {
+ coords.push(coord2);
+ }
+ }
+ if (coords.length === 0) {
+ return {error: true};
+ }
+ return {
+ coordinates: coords,
+ };
+ }
+}
+
+class CoordinatesProvider {
+ name = 'Coordinates';
+
+ static regexps = {
+ // This regexp wag generated using script at https://gist.github.com/wladich/3d15edc8fcd8b735ac883ef60fe10bfe
+ // It matches all unicode characters except Lu (Uppercase Letter), Ll (Lowercase Letter)
+ // and [0123456789,.-]. It ignores unassigned code points (Cn) and characters that are removed after NFKC
+ // normalization.
+ // Manually added: "oO" (lat), "оО" (rus)
+ // eslint-disable-next-line max-len, no-control-regex, no-misleading-character-class
+ symbols: /[OoОо\u0000-\u002b\u002f\u003a-\u0040\u005b-\u0060\u007b-\u00bf\u00d7\u00f7\u01bb\u01c0-\u01cc\u0294\u02b9-\u036f\u0375\u03f6\u0482-\u0489\u0559-\u055f\u0589-\u109f\u10fb\u10fc\u1100-\u139f\u1400-\u1c7f\u1cc0-\u1cff\u1d2f-\u1d6a\u1dc0-\u1dff\u1f88-\u1f8f\u1f98-\u1f9f\u1fa8-\u1faf\u1fbc-\u1fc1\u1fcc-\u1fcf\u1ffc-\u2131\u213a-\u214d\u214f-\u2182\u2185-\u2bff\u2ce5-\u2cea\u2cef-\u2cf1\u2cf9-\u2cff\u2d30-\ua63f\ua66e-\ua67f\ua69e-\ua721\ua788-\ua78a\ua78f\ua7f7-\ua7f9\ua7fb-\uab2f\uab5b-\uab5f\uabc0-\uffff]/gu,
+ northernHemishphere: /[Nn]|с *ш?/gu,
+ southernHemishphere: /[Ss]|ю *ш?/gu,
+ westernHemishphere: /[Ww]|з *д?/gu,
+ easternHemishphere: /[EeЕе]|в *д?/gu, // second Ее is cyrillic
+ };
+
+ static parsers = [CoordinatesDMS, CoordinatesDM, CoordinatesD, CoordinatesDSigned];
+
+ normalizeInput(s) {
+ s = s.normalize('NFKC'); // convert subscripts and superscripts to normal chars
+ s = ' ' + s + ' ';
+ // replace everything that is not letter, number, minus, dot or comma to space
+ s = s.replace(CoordinatesProvider.regexps.symbols, ' ');
+ // remove all dots and commas if they are not between digits
+ s = s.replace(/[,.](?=\D)/gu, ' ');
+ s = s.replace(/(\D)[,.]/gu, '$1 '); // lookbehind is not supported in all browsers
+ // if dot is likely to be used as decimal separator, remove all commas
+ if (s.includes('.')) {
+ s = s.replace(/,/gu, ' ');
+ } else {
+ // otherwise comma is decimal separator
+ s = s.replace(/,/gu, '.');
+ }
+ s = s.replace(/-(?=\D)/gu, ' '); // remove all minuses that are not in the beginning of number
+ s = s.replace(/([^ ])-/gu, '$1 '); // lookbehind is not supported in all browsers
+
+ s = s.replace(CoordinatesProvider.regexps.northernHemishphere, ' N ');
+ s = s.replace(CoordinatesProvider.regexps.southernHemishphere, ' S ');
+ s = s.replace(CoordinatesProvider.regexps.westernHemishphere, ' W ');
+ s = s.replace(CoordinatesProvider.regexps.easternHemishphere, ' E ');
+
+ s = s.replace(/ +/gu, ' '); // compress whitespaces
+ s = s.trim();
+ return s;
+ }
+
+ isOurQuery(query) {
+ const coordFieldRe = new RegExp(`^((${reHemisphere})|(${reSignedFractional}))$`, 'u');
+ const coordNumbersFieldRe = new RegExp(`^(${reSignedFractional})$`, 'u');
+ const fields = this.normalizeInput(query).split(' ');
+ return (
+ fields.length > 1 &&
+ fields.every((field) => field.match(coordFieldRe)) &&
+ fields.some((field) => field.match(coordNumbersFieldRe))
+ );
+ }
+
+ async search(query) {
+ const s = this.normalizeInput(query);
+ for (let parser of CoordinatesProvider.parsers) {
+ const result = parser.fromString(s);
+ if (!result.error) {
+ const resultItems = result.coordinates.map((it) => {
+ const coordStrings = it.format();
+ return {
+ title: `${coordStrings.latitude} ${coordStrings.longitude}`,
+ latlng: it.getLatLng(),
+ zoom: 17,
+ category: 'Coordinates',
+ address: null,
+ icon: null,
+ };
+ });
+ return {
+ results: resultItems,
+ };
+ }
+ }
+ return {error: 'Invalid coordinates'};
+ }
+}
+
+export {CoordinatesProvider};
diff --git a/src/lib/leaflet.control.search/providers/index.js b/src/lib/leaflet.control.search/providers/index.js
@@ -0,0 +1,13 @@
+import {MapyCzProvider} from './mapycz';
+import {PhotonProvider} from './photon';
+import {LinksProvider} from './links';
+import {CoordinatesProvider} from "./coordinates";
+
+const providers = {
+ mapycz: MapyCzProvider,
+ photon: PhotonProvider,
+};
+
+const magicProviders = [LinksProvider, CoordinatesProvider];
+
+export {providers, magicProviders};
diff --git a/src/lib/leaflet.control.search/providers/links.js b/src/lib/leaflet.control.search/providers/links.js
@@ -0,0 +1,200 @@
+import L from 'leaflet';
+
+const MAX_ZOOM = 18;
+const MESSAGE_LINK_MALFORMED = 'Invalid coordinates in {name} link';
+
+function makeSearchResults(lat, lon, zoom, title) {
+ if (
+ isNaN(zoom) ||
+ isNaN(lat) ||
+ isNaN(lon) ||
+ zoom < 0 ||
+ zoom > 25 ||
+ lat < -90 ||
+ lat > 90 ||
+ lon < -180 ||
+ lon > 180
+ ) {
+ throw new Error('Invalid view state value');
+ }
+ if (zoom > MAX_ZOOM) {
+ zoom = MAX_ZOOM;
+ }
+
+ return {
+ results: [
+ {
+ latlng: L.latLng(lat, lon),
+ zoom,
+ title,
+ category: null,
+ address: null,
+ icon: null,
+ },
+ ],
+ };
+}
+
+const YandexMapsUrl = {
+ isOurUrl: function(url) {
+ return Boolean(url.hostname.match(/\byandex\.[^.]+$/u) && url.pathname.match(/^\/maps\//u));
+ },
+
+ getResults: function(url) {
+ const paramLl = url.searchParams.get('ll');
+ const paramZ = url.searchParams.get('z');
+ try {
+ const [lon, lat] = paramLl.split(',').map(parseFloat);
+ const zoom = Math.round(parseFloat(paramZ));
+ return makeSearchResults(lat, lon, zoom, 'Yandex map view');
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Yandex'})};
+ }
+ },
+};
+
+const GoogleMapsSimpleUrl = {
+ viewRe: /\/@([-\d.]+),([-\d.]+),([\d.]+)z(?:\/|$)/u,
+
+ isOurUrl: function url(url) {
+ return Boolean(url.pathname.match(this.viewRe));
+ },
+
+ getResults: function(url) {
+ const path = url.pathname;
+ const viewMatch = path.match(this.viewRe);
+ const titleMatch = path.match(/\/place\/([^/]+)/u);
+ let title = titleMatch?.[1];
+ if (title) {
+ title = 'Google map - ' + decodeURIComponent(title).replace(/\+/gu, ' ');
+ } else {
+ title = 'Google map view';
+ }
+ try {
+ const lat = parseFloat(viewMatch[1]);
+ const lon = parseFloat(viewMatch[2]);
+ const zoom = Math.round(parseFloat(viewMatch[3]));
+ return makeSearchResults(lat, lon, zoom, title);
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Google'})};
+ }
+ },
+};
+
+const GoogleMapsQueryUrl = {
+ zoom: 17,
+ title: 'Google map view',
+
+ isOurUrl: function(url) {
+ return url.searchParams.has('q');
+ },
+
+ getResults: function(url) {
+ const data = url.searchParams.get('q');
+ const m = data.match(/^(?:loc:)?([-\d.]+),([-\d.]+)$/u);
+ try {
+ const lat = parseFloat(m[1]);
+ const lon = parseFloat(m[2]);
+ return makeSearchResults(lat, lon, this.zoom, this.title);
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Google'})};
+ }
+ }
+};
+
+const GoogleMapsUrl = {
+ subprocessors: [GoogleMapsSimpleUrl, GoogleMapsQueryUrl],
+
+ isOurUrl: function(url) {
+ return Boolean(url.hostname.match(/\bgoogle\.[^.]+$/u) && url.pathname.match(/^\/maps(\/|$)/u));
+ },
+
+ getResults: function(url) {
+ for (let subprocessor of this.subprocessors) {
+ if (subprocessor.isOurUrl(url)) {
+ return subprocessor.getResults(url);
+ }
+ }
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Google'})};
+ },
+};
+
+const MapyCzUrl = {
+ isOurUrl: function(url) {
+ return Boolean(url.hostname.match(/\bmapy\.cz$/u));
+ },
+
+ getResults: function(url) {
+ try {
+ const lon = parseFloat(url.searchParams.get('x'));
+ const lat = parseFloat(url.searchParams.get('y'));
+ const zoom = Math.round(parseFloat(url.searchParams.get('z')));
+ return makeSearchResults(lat, lon, zoom, 'Mapy.cz view');
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Mapy.cz'})};
+ }
+ },
+};
+
+const OpenStreetMapUrl = {
+ isOurUrl: function(url) {
+ return Boolean(url.hostname.match(/\bopenstreetmap\.org$/u));
+ },
+
+ getResults: function(url) {
+ const m = url.hash.match(/map=([\d.]+)\/([\d.-]+)\/([\d.-]+)/u);
+ try {
+ const zoom = Math.round(parseFloat(m[1]));
+ const lat = parseFloat(m[2]);
+ const lon = parseFloat(m[3]);
+ return makeSearchResults(lat, lon, zoom, 'OpenStreetMap view');
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'OpenStreetMap'})};
+ }
+ },
+};
+
+const NakarteUrl = {
+ isOurUrl: function(url) {
+ return url.hostname.match(/\bnakarte\b/u) || !this.getResults(url).error;
+ },
+
+ getResults: function(url) {
+ const m = url.hash.match(/\bm=([\d]+)\/([\d.-]+)\/([\d.-]+)/u);
+ try {
+ const zoom = Math.round(parseFloat(m[1]));
+ const lat = parseFloat(m[2]);
+ const lon = parseFloat(m[3]);
+ return makeSearchResults(lat, lon, zoom, 'Nakarte view');
+ } catch (_) {
+ return {error: L.Util.template(MESSAGE_LINK_MALFORMED, {name: 'Nakarte'})};
+ }
+ },
+};
+
+const urlProcessors = [YandexMapsUrl, GoogleMapsUrl, MapyCzUrl, OpenStreetMapUrl, NakarteUrl];
+
+class LinksProvider {
+ name = 'Links';
+
+ isOurQuery(query) {
+ return Boolean(query.match(/^https?:\/\//u));
+ }
+
+ async search(query) {
+ let url;
+ try {
+ url = new URL(query);
+ } catch (e) {
+ return {error: 'Invalid link'};
+ }
+ for (let processor of urlProcessors) {
+ if (processor.isOurUrl(url)) {
+ return processor.getResults(url);
+ }
+ }
+ return {error: 'Unsupported link'};
+ }
+}
+
+export {LinksProvider};
diff --git a/src/lib/leaflet.control.search/providers/mapycz/categories.csv b/src/lib/leaflet.control.search/providers/mapycz/categories.csv
@@ -0,0 +1,149 @@
+id,en,ru
+3,Island,Остров
+4,Mountain range,Горная область
+6,Protected area,Охраняемная территория
+7,Water body,Водоём
+9,Local name,Местное название
+10,National park,Национальный парк
+12,Name of the forest or field,Урочище
+13,Historic Territory,Историческая территория
+15,City district,Район города
+21,Road,Дорога
+105,Glacier,Ледник
+106,Swamp,Болото
+107,Peninsula,Полуостров
+108,Desert,Пустыня
+111,Low land,Низменность
+114,River,Река
+116,Stream,Ручей
+117,Military area,Военный полигон
+118,Administrative structure,Регион
+119,Inhabited area,Населенный пункт
+120,Industrial zone,Промзона
+122,District,Пригород
+201,Capital city,Столица
+202,Town,Город
+203,Municipality,Город
+204,Village,Деревня
+205,City,Город
+206,Village,Деревня
+210,Country,Страна
+220,Attraction,Достопримечательность
+230,Аddress,Адрес
+250,City district,Район города
+1012,Border crossing,Погранпереход
+1015,Cableway,Канатная дорога
+1016,International airport,Международный аэропорт
+1017,Airport,Аэропорт
+1018,Airport,Аэропорт
+1019,Airport Terminal,Терминал аэропорта
+1022,Subway station,Станция метро
+1023,Helipad,Вертолетная площадка
+1030,Port,Порт
+1042,Subway line,Линия метро
+1045,Subway line,Линия метро
+1114,Tourist signpost,Указатель троп
+1115,Bunker,Бункер
+1117,Tourist signpost,Указатель троп
+1119,Mine,Шахта
+1122,High building,Высокое здание
+1127,Transmission tower,Радио башня
+1131,Attraction,Достопримечательность
+1136,Tunnel,Тонель
+1210,Archeological site,Археологические раскопки
+1212,Castle,Замок
+1216,Monastery,Монастырь
+1217,Church,Церковь
+1224,Memorial,Памятник
+1234,Park,Парк
+1236,Cemetery,Кладбище
+1246,Castle,Замок
+1249,Temple,Храм/Синагога/Мечеть
+1250,Attraction,Достопримечательность
+1261,Amusement park,Парк развлечений
+1310,UNESCO site,объект ЮНЕСКО
+1411,Mountain,Вершина
+1412,Information board,Информационный стенд
+1413,Protected area,Охраняемная территория
+1415,Water body,Водоём
+1416,"Mountain, volcano","Вершина, вулкан"
+1417,Natural swimming pool,Место купания
+1418,Water reservoir,Водохранилише или водонапорная башня
+1427,Mountain pass,Перевал
+1428,Rocks,Скалы
+1429,"Well, spring",Источник
+1430,Waterfall,Водопад
+1433,Beach,Пляж
+1443,Scenic viewpoint,Обзорная точка
+1642,Cycling route,Велосипедный маршрут
+4100,Part of building,Корпус здания
+5018,Power plant,Электростанция
+5115,School,Школа
+5118,Administrative office,Администрация
+5124,Fire station,Пожарное депо
+5215,Library,Библиотека
+5216,Museum,Музей
+5217,Zoo,Зоопарк
+5306,Money exchange,Обмен валют
+5309,LPG station,Газовая заправка
+5310,Auto service,Автосервис
+5313,Petrol station,Заправка
+5315,Shopping mall,Торговый центр
+5319,Bike shop,Веломагазин
+5322,Restaurant,Ресторан
+5324,Fastfood,Фастфуд
+5326,Cafe,Кафе
+5328,Bar,Бар
+5403,Sauna,Сауна
+5417,Gym,Тренажерный зал
+5511,Hotel,Гостиница
+5514,Motel,Мотель
+5516,Guest house,Гостевой дом
+5521,Apartments,Апартаменты
+5522,Holiday house,Гостевой дом
+5611,Pharmacy,Аптека
+5613,Hospital,Больница
+5619,Wine shop,Вино
+5623,Electrical supplies,Электротовары
+5624,Hobby shop,Товары для дома
+5627,Sports equipment,Спортивный магазин
+5628,Grocery store,Продуктовый магазин
+5630,Auto service,Автосервис
+5631,Travel service,Туристические услуги
+5632,Accounting and tax records,Бухгалтерия и налоги
+5633,Shop,Магазин
+5634,Advertising and marketing services,Рекламные услуги
+5635,Sales of heat pumps,Продажа тепловых насосов
+5636,Factory,Фабрика
+5638,Real estate agents,Агент по недвижимости
+5639,Application service providers,Application service providers
+5641,Construction works,Строительные работы
+5642,Sale of pharmaceuticals,Продажа лекарств
+5653,Waste disposal,Утилизация отходов
+5656,Alergologist,Алерголог
+5668,Cosmetics,Косметика
+5669,Sales of paints,Продажа красок
+5673,Engineering services,Инженерные работы
+5676,Construction company,Строительная компания
+5679,Joinery and carpentry,Столярные работы
+5683,Jewelry,Ювелирный магазин
+5684,Sale of cosmetics,Продажа косметики
+5685,Clothes shop,Магазин одежды
+5686,Bakery,Пекарня
+5691,Bookshop,Книжный магазин
+5694,"Fruits, vegetables",Офощи-фрукты
+5699,Printing house,Типография
+5701,Shoe shop,Обувбной магазин
+5753,Building,Здание
+5759,Water treatment facility,Станция подготовки воды
+5760,Scientific workplace,Научная лаборатория
+6001,Bus stop,Остановка автобуса
+6506,Tram stop,Остановка трамвая
+6507,Bus stop,Остановка автобуса
+6508,Trolleybus stop,Остановка тролейбуса
+6511,Train station,Ж/д станция
+6512,Train station,Ж/д станция
+6513,Bus terminal,Автовокзал
+6519,Ski lift station,Станция канатной дороги
+11425,Mountain ridge,Хребет
+15118,Administrative office,Администрация
diff --git a/src/lib/leaflet.control.search/providers/mapycz/icons.json b/src/lib/leaflet.control.search/providers/mapycz/icons.json
@@ -0,0 +1,675 @@
+{
+ "998": 548,
+ "999": 449,
+ "1000": 292,
+ "1001": 408,
+ "1002": 43,
+ "1003": 43,
+ "1006": 43,
+ "1007": 25,
+ "1008": 492,
+ "1010": 396,
+ "1012": 52,
+ "1013": 52,
+ "1014": 52,
+ "1015": 70,
+ "1016": 73,
+ "1017": 73,
+ "1018": 73,
+ "1019": 407,
+ "1020": 493,
+ "1021": 477,
+ "1022": 391,
+ "1023": 527,
+ "1024": 70,
+ "1025": 512,
+ "1026": 533,
+ "1027": 531,
+ "1028": 532,
+ "1029": 52,
+ "1030": 125,
+ "1031": 128,
+ "1032": 95,
+ "1033": 10,
+ "1034": 405,
+ "1035": 52,
+ "1036": 415,
+ "1037": 245,
+ "1038": 562,
+ "1039": 563,
+ "1042": 391,
+ "1043": 476,
+ "1044": 196,
+ "1045": 391,
+ "1049": 196,
+ "1050": 147,
+ "1051": 11,
+ "1052": 11,
+ "1053": 389,
+ "1054": 110,
+ "1055": 371,
+ "1056": 372,
+ "1057": 390,
+ "1059": 11,
+ "1060": 521,
+ "1061": 417,
+ "1062": 11,
+ "1063": 417,
+ "1064": 521,
+ "1065": 519,
+ "1066": 528,
+ "1067": 567,
+ "1070": 522,
+ "1071": 520,
+ "1089": 491,
+ "1090": 523,
+ "1100": 525,
+ "1101": 524,
+ "1102": 526,
+ "1103": 520,
+ "1105": 64,
+ "1108": 405,
+ "1109": 335,
+ "1110": 386,
+ "1111": 221,
+ "1112": 270,
+ "1113": 369,
+ "1114": 384,
+ "1115": 34,
+ "1116": 361,
+ "1117": 384,
+ "1118": 135,
+ "1119": 312,
+ "1120": 312,
+ "1121": 308,
+ "1122": 179,
+ "1123": 342,
+ "1124": 499,
+ "1125": 34,
+ "1126": 341,
+ "1127": 341,
+ "1128": 51,
+ "1129": 63,
+ "1130": 462,
+ "1131": 156,
+ "1132": 462,
+ "1133": 273,
+ "1134": 204,
+ "1135": 340,
+ "1136": 115,
+ "1137": 303,
+ "1138": 383,
+ "1139": 498,
+ "1140": 162,
+ "1141": 517,
+ "1142": 551,
+ "1143": 384,
+ "1144": 543,
+ "1145": 568,
+ "1146": 569,
+ "1210": 24,
+ "1211": 46,
+ "1212": 51,
+ "1213": 50,
+ "1214": 59,
+ "1215": 60,
+ "1216": 63,
+ "1217": 66,
+ "1218": 565,
+ "1219": 46,
+ "1220": 46,
+ "1221": 3,
+ "1222": 514,
+ "1224": 564,
+ "1225": 143,
+ "1226": 152,
+ "1227": 51,
+ "1228": 380,
+ "1229": 187,
+ "1230": 188,
+ "1231": 190,
+ "1232": 227,
+ "1233": 37,
+ "1234": 351,
+ "1235": 474,
+ "1236": 242,
+ "1237": 244,
+ "1238": 134,
+ "1239": 235,
+ "1240": 565,
+ "1241": 191,
+ "1242": 76,
+ "1243": 188,
+ "1244": 115,
+ "1245": 381,
+ "1246": 187,
+ "1247": 188,
+ "1248": 190,
+ "1249": 500,
+ "1250": 380,
+ "1251": 424,
+ "1252": 571,
+ "1253": 51,
+ "1254": 140,
+ "1255": 236,
+ "1256": 222,
+ "1257": 22,
+ "1258": 300,
+ "1259": 244,
+ "1260": 178,
+ "1261": 183,
+ "1262": 456,
+ "1263": 59,
+ "1264": 22,
+ "1265": 414,
+ "1266": 416,
+ "1267": 424,
+ "1268": 382,
+ "1269": 505,
+ "1270": 497,
+ "1271": 450,
+ "1272": 544,
+ "1273": 564,
+ "1274": 564,
+ "1275": 566,
+ "1276": 566,
+ "1277": 564,
+ "1310": 108,
+ "1311": 157,
+ "1313": 338,
+ "1410": 367,
+ "1411": 347,
+ "1412": 454,
+ "1413": 368,
+ "1414": 55,
+ "1415": 68,
+ "1416": 129,
+ "1417": 388,
+ "1418": 68,
+ "1420": 68,
+ "1421": 68,
+ "1422": 459,
+ "1423": 368,
+ "1424": 129,
+ "1425": 139,
+ "1426": 534,
+ "1427": 159,
+ "1428": 139,
+ "1429": 363,
+ "1430": 173,
+ "1431": 159,
+ "1432": 186,
+ "1433": 114,
+ "1434": 453,
+ "1435": 139,
+ "1437": 97,
+ "1438": 138,
+ "1439": 355,
+ "1440": 173,
+ "1441": 249,
+ "1442": 249,
+ "1443": 177,
+ "1444": 177,
+ "1445": 367,
+ "1446": 124,
+ "1447": 55,
+ "1449": 129,
+ "1450": 316,
+ "1452": 71,
+ "1453": 475,
+ "1454": 475,
+ "1455": 186,
+ "1456": 515,
+ "1457": 111,
+ "1599": 131,
+ "1601": 160,
+ "1602": 170,
+ "1603": 84,
+ "1604": 87,
+ "1605": 89,
+ "1606": 85,
+ "1607": 32,
+ "1608": 148,
+ "1609": 111,
+ "1610": 57,
+ "1611": 53,
+ "1612": 93,
+ "1613": 180,
+ "1614": 95,
+ "1615": 184,
+ "1616": 171,
+ "1617": 161,
+ "1618": 150,
+ "1619": 126,
+ "1620": 460,
+ "1621": 89,
+ "1622": 88,
+ "1623": 90,
+ "1624": 86,
+ "1625": 410,
+ "1626": 149,
+ "1627": 112,
+ "1628": 58,
+ "1629": 54,
+ "1630": 54,
+ "1635": 162,
+ "1636": 127,
+ "1637": 127,
+ "1638": 127,
+ "1639": 90,
+ "1640": 126,
+ "1641": 161,
+ "1642": 494,
+ "1643": 408,
+ "1644": 495,
+ "2000": 2,
+ "2001": 417,
+ "2002": 417,
+ "2003": 389,
+ "2004": 110,
+ "2005": 13,
+ "2006": 13,
+ "2007": 2,
+ "2008": 13,
+ "2021": 5,
+ "2022": 5,
+ "2023": 9,
+ "2024": 4,
+ "2025": 10,
+ "2026": 20,
+ "2027": 10,
+ "2028": 10,
+ "2029": 529,
+ "2030": 1,
+ "2031": 15,
+ "2032": 4,
+ "2033": 4,
+ "2034": 8,
+ "2035": 13,
+ "2036": 6,
+ "2037": 16,
+ "2039": 4,
+ "2040": 5,
+ "2041": 1,
+ "2042": 1,
+ "2043": 4,
+ "2044": 7,
+ "2045": 4,
+ "2046": 21,
+ "2047": 17,
+ "2048": 11,
+ "2049": 1,
+ "2050": 1,
+ "2052": 5,
+ "2053": 13,
+ "2054": 8,
+ "2055": 13,
+ "2056": 16,
+ "2057": 13,
+ "2058": 5,
+ "2059": 1,
+ "2060": 20,
+ "2061": 18,
+ "2062": 7,
+ "2069": 4,
+ "2070": 10,
+ "2071": 529,
+ "2072": 545,
+ "2073": 546,
+ "2074": 547,
+ "2075": 528,
+ "2100": 347,
+ "2101": 347,
+ "2105": 15,
+ "2106": 397,
+ "2110": 10,
+ "3003": 339,
+ "3201": 301,
+ "3205": 292,
+ "3206": 293,
+ "3207": 293,
+ "3211": 239,
+ "3212": 240,
+ "3213": 240,
+ "3221": 305,
+ "3222": 306,
+ "3223": 306,
+ "3231": 343,
+ "3232": 344,
+ "3241": 232,
+ "3242": 233,
+ "3243": 233,
+ "3251": 316,
+ "3253": 317,
+ "3261": 237,
+ "3262": 238,
+ "3271": 295,
+ "3272": 296,
+ "3281": 352,
+ "3282": 353,
+ "3285": 245,
+ "3286": 246,
+ "3291": 286,
+ "3301": 280,
+ "3303": 282,
+ "3311": 283,
+ "3321": 356,
+ "3322": 357,
+ "3323": 357,
+ "3325": 247,
+ "3326": 248,
+ "3331": 313,
+ "3332": 314,
+ "3341": 250,
+ "3351": 271,
+ "3352": 272,
+ "3353": 272,
+ "3355": 330,
+ "3356": 331,
+ "3357": 331,
+ "3361": 348,
+ "3362": 349,
+ "3363": 349,
+ "3365": 310,
+ "3366": 311,
+ "3367": 311,
+ "3371": 228,
+ "3372": 229,
+ "3373": 229,
+ "3375": 223,
+ "3376": 224,
+ "3377": 224,
+ "3381": 289,
+ "3382": 290,
+ "3383": 290,
+ "3391": 225,
+ "3392": 226,
+ "3393": 226,
+ "3395": 321,
+ "3396": 322,
+ "4009": 387,
+ "4010": 382,
+ "4100": 143,
+ "5013": 455,
+ "5014": 461,
+ "5015": 452,
+ "5017": 455,
+ "5018": 455,
+ "5112": 119,
+ "5113": 117,
+ "5114": 122,
+ "5115": 142,
+ "5116": 141,
+ "5117": 320,
+ "5118": 163,
+ "5120": 398,
+ "5121": 354,
+ "5122": 122,
+ "5123": 412,
+ "5124": 301,
+ "5125": 518,
+ "5210": 450,
+ "5211": 39,
+ "5212": 44,
+ "5213": 158,
+ "5214": 62,
+ "5215": 64,
+ "5216": 91,
+ "5217": 189,
+ "5218": 38,
+ "5219": 502,
+ "5220": 501,
+ "5221": 503,
+ "5222": 504,
+ "5223": 402,
+ "5224": 500,
+ "5300": 137,
+ "5306": 28,
+ "5307": 535,
+ "5308": 36,
+ "5309": 35,
+ "5310": 26,
+ "5311": 28,
+ "5312": 27,
+ "5313": 36,
+ "5314": 35,
+ "5315": 419,
+ "5316": 113,
+ "5317": 423,
+ "5318": 130,
+ "5319": 65,
+ "5321": 549,
+ "5322": 133,
+ "5323": 26,
+ "5324": 136,
+ "5325": 536,
+ "5326": 38,
+ "5327": 136,
+ "5328": 364,
+ "5329": 362,
+ "5330": 182,
+ "5331": 496,
+ "5332": 136,
+ "5333": 130,
+ "5400": 23,
+ "5401": 69,
+ "5402": 350,
+ "5403": 516,
+ "5410": 145,
+ "5411": 31,
+ "5412": 45,
+ "5413": 471,
+ "5414": 379,
+ "5415": 67,
+ "5416": 109,
+ "5417": 121,
+ "5418": 144,
+ "5419": 146,
+ "5420": 153,
+ "5421": 84,
+ "5422": 75,
+ "5423": 196,
+ "5424": 396,
+ "5425": 47,
+ "5426": 197,
+ "5428": 473,
+ "5429": 75,
+ "5430": 254,
+ "5431": 253,
+ "5432": 197,
+ "5433": 195,
+ "5434": 162,
+ "5435": 83,
+ "5436": 378,
+ "5437": 374,
+ "5438": 376,
+ "5439": 377,
+ "5440": 375,
+ "5441": 421,
+ "5442": 420,
+ "5443": 422,
+ "5444": 458,
+ "5445": 374,
+ "5446": 472,
+ "5447": 470,
+ "5448": 469,
+ "5449": 464,
+ "5450": 468,
+ "5451": 508,
+ "5452": 469,
+ "5453": 472,
+ "5454": 470,
+ "5455": 464,
+ "5456": 144,
+ "5457": 467,
+ "5458": 466,
+ "5459": 465,
+ "5460": 463,
+ "5461": 382,
+ "5462": 506,
+ "5463": 47,
+ "5464": 375,
+ "5465": 253,
+ "5466": 144,
+ "5467": 375,
+ "5510": 49,
+ "5511": 49,
+ "5512": 61,
+ "5513": 132,
+ "5514": 49,
+ "5515": 49,
+ "5516": 49,
+ "5517": 49,
+ "5518": 49,
+ "5520": 37,
+ "5521": 49,
+ "5522": 49,
+ "5523": 49,
+ "5530": 132,
+ "5610": 231,
+ "5611": 72,
+ "5612": 72,
+ "5613": 104,
+ "5614": 418,
+ "5615": 405,
+ "5616": 164,
+ "5617": 49,
+ "5618": 166,
+ "5619": 168,
+ "5620": 165,
+ "5621": 165,
+ "5622": 509,
+ "5623": 539,
+ "5624": 540,
+ "5625": 101,
+ "5626": 541,
+ "5627": 542,
+ "5628": 423,
+ "5629": 451,
+ "5630": 397,
+ "5631": 407,
+ "5632": 27,
+ "5633": 423,
+ "5634": 403,
+ "5635": 399,
+ "5636": 398,
+ "5637": 401,
+ "5638": 402,
+ "5639": 400,
+ "5640": 397,
+ "5641": 404,
+ "5642": 405,
+ "5643": 163,
+ "5644": 354,
+ "5645": 64,
+ "5646": 141,
+ "5647": 142,
+ "5648": 165,
+ "5649": 84,
+ "5650": 169,
+ "5651": 397,
+ "5652": 457,
+ "5653": 137,
+ "5654": 382,
+ "5655": 30,
+ "5656": 405,
+ "5657": 30,
+ "5658": 156,
+ "5659": 443,
+ "5660": 2,
+ "5661": 425,
+ "5662": 426,
+ "5663": 427,
+ "5664": 428,
+ "5665": 429,
+ "5666": 430,
+ "5667": 431,
+ "5668": 432,
+ "5669": 433,
+ "5670": 434,
+ "5671": 435,
+ "5672": 436,
+ "5673": 437,
+ "5674": 438,
+ "5675": 439,
+ "5676": 440,
+ "5677": 441,
+ "5678": 442,
+ "5679": 443,
+ "5680": 444,
+ "5681": 445,
+ "5682": 446,
+ "5683": 447,
+ "5684": 103,
+ "5685": 488,
+ "5686": 485,
+ "5687": 491,
+ "5688": 490,
+ "5689": 538,
+ "5690": 168,
+ "5691": 537,
+ "5692": 486,
+ "5693": 487,
+ "5694": 484,
+ "5695": 483,
+ "5696": 482,
+ "5697": 481,
+ "5698": 480,
+ "5699": 479,
+ "5701": 489,
+ "5702": 510,
+ "5703": 511,
+ "5704": 550,
+ "5705": 444,
+ "5753": 570,
+ "5754": 152,
+ "5755": 507,
+ "5756": 570,
+ "5757": 398,
+ "5758": 360,
+ "5759": 365,
+ "5760": 380,
+ "5761": 501,
+ "5762": 44,
+ "5763": 504,
+ "5764": 504,
+ "5765": 484,
+ "6000": 155,
+ "6001": 198,
+ "6002": 336,
+ "6006": 125,
+ "6007": 169,
+ "6008": 169,
+ "6009": 279,
+ "6010": 260,
+ "6011": 262,
+ "6012": 263,
+ "6013": 258,
+ "6014": 261,
+ "6015": 259,
+ "6020": 70,
+ "6500": 264,
+ "6501": 267,
+ "6502": 265,
+ "6503": 266,
+ "6504": 268,
+ "6505": 269,
+ "6506": 334,
+ "6507": 200,
+ "6508": 337,
+ "6509": 70,
+ "6510": 125,
+ "6511": 169,
+ "6512": 169,
+ "6513": 279,
+ "6519": 70,
+ "11026": 513,
+ "11415": 68,
+ "11425": 139,
+ "11431": 159,
+ "11450": 316,
+ "15118": 163,
+ "20000": 448,
+ "55555": 332,
+ "68767678": 506
+}
diff --git a/src/lib/leaflet.control.search/providers/mapycz/index.js b/src/lib/leaflet.control.search/providers/mapycz/index.js
@@ -0,0 +1,86 @@
+import L from 'leaflet';
+import {fetch} from '~/lib/xhr-promise';
+import logging from '~/lib/logging';
+
+import {BaseProvider} from '../remoteBase';
+
+import _categories from './categories.csv';
+import icons from './icons.json';
+
+const categories = Object.assign({}, ..._categories.map((it) => ({[String(it.id)]: it})));
+
+const MapyCzProvider = BaseProvider.extend({
+ name: 'Mapy.cz',
+
+ options: {
+ apiUrl: 'https://api.mapy.cz/suggest/',
+ attribution: {
+ text: 'Mapy.cz',
+ url: 'https://mapy.cz',
+ },
+ delay: 300,
+ languages: ['en', 'cs', 'de', 'pl', 'sk', 'ru', 'es', 'fr'],
+ categoriesLanguages: ['en', 'ru'],
+ defaultLanguage: 'en',
+ },
+
+ initialize: function(options) {
+ BaseProvider.prototype.initialize.call(this, options);
+ this.langStr = this.getRequestLanguages(this.options.languages).join(',');
+ this.categoriesLanguage = this.getRequestLanguages(
+ this.options.categoriesLanguages,
+ this.options.defaultLanguage
+ )[0];
+ },
+
+ search: async function(query, {latlng, zoom}) {
+ if (!(await this.waitNoNewRequestsSent())) {
+ return {error: 'Request cancelled'};
+ }
+ const url = new URL(this.options.apiUrl);
+ url.searchParams.append('phrase', query);
+ url.searchParams.append('lat', latlng.lat);
+ url.searchParams.append('lon', latlng.lng);
+ url.searchParams.append('zoom', zoom);
+ url.searchParams.append('lang', this.langStr);
+ if (this.options.maxResponses) {
+ url.searchParams.append('count', this.options.maxResponses);
+ }
+ let xhr;
+ try {
+ xhr = await fetch(url.href, {responseType: 'json', timeout: 5000});
+ } catch (e) {
+ if (e.name === 'XMLHttpRequestPromiseError') {
+ logging.captureException(e, 'Error response from mapy.cz search api');
+ return {error: `Search failed: ${e.message}`};
+ }
+ throw e;
+ }
+ const places = xhr.responseJSON.result.map((it) => {
+ const data = it.userData;
+ const iconId = icons[data.poiTypeId];
+ const icon = iconId ? `https://api.mapy.cz/poiimg/icon/${iconId}?scale=1` : null;
+ return {
+ bbox: L.latLngBounds([data.bbox[0], data.bbox[1]], [data.bbox[2], data.bbox[3]]),
+ latlng: L.latLng(data.latitude, data.longitude),
+ title: data.suggestFirstRow,
+ address: data.suggestSecondRow,
+ category: categories[data.poiTypeId]?.[this.categoriesLanguage] || data.suggestThirdRow || null,
+ icon,
+ };
+ });
+ const poiIds = xhr.responseJSON.result
+ .filter((it) => Boolean(it.userData.poiTypeId))
+ .map((it) => ({
+ typeId: it.userData.poiTypeId,
+ poiId: it.userData.id,
+ source: it.userData.source,
+ }));
+ if (poiIds.length > 0) {
+ logging.logEvent('SearchMapyCzPoiIds', {poiIds});
+ }
+ return {results: places};
+ },
+});
+
+export {MapyCzProvider};
diff --git a/src/lib/leaflet.control.search/providers/photon.js b/src/lib/leaflet.control.search/providers/photon.js
@@ -0,0 +1,93 @@
+import L from 'leaflet';
+import {fetch} from '~/lib/xhr-promise';
+import logging from '~/lib/logging';
+
+import {BaseProvider} from './remoteBase';
+
+const PhotonProvider = BaseProvider.extend({
+ name: 'Photon',
+
+ options: {
+ apiUrl: 'https://photon.komoot.de/api/',
+ attribution: {
+ text: 'Photon by Komoot',
+ url: 'https://photon.komoot.de/',
+ },
+ delay: 700,
+ languages: ['en', 'de', 'fr', 'it'],
+ defaultLanguage: 'en',
+ },
+
+ initialize: function(options) {
+ BaseProvider.prototype.initialize.call(this, options);
+ this.lang = this.getRequestLanguages(this.options.languages, this.options.defaultLanguage)[0];
+ },
+
+ search: async function(query, {latlng}) {
+ if (!await this.waitNoNewRequestsSent()) {
+ return {error: 'Request cancelled'};
+ }
+ const url = new URL(this.options.apiUrl);
+ if (this.options.maxResponses) {
+ url.searchParams.append('limit', this.options.maxResponses);
+ }
+ url.searchParams.append('q', query);
+ url.searchParams.append('lang', 'en');
+ url.searchParams.append('lat', latlng.lat);
+ url.searchParams.append('lon', latlng.lng);
+ let xhr;
+ try {
+ xhr = await fetch(url.href, {responseType: 'json', timeout: 5000});
+ } catch (e) {
+ if (e.name === 'XMLHttpRequestPromiseError') {
+ logging.captureException(e, 'Error response from photon search api');
+ return {error: `Search failed: ${e.message}`};
+ }
+ throw e;
+ }
+ const places = xhr.responseJSON.features.map((feature) => {
+ const properties = feature.properties;
+ let address = [
+ properties.street,
+ properties.housenumber,
+ properties.city,
+ properties.state,
+ properties.country,
+ ]
+ .filter((it) => it)
+ .join(', ');
+ let bbox = null;
+ let zoom = null;
+ if (properties.extent) {
+ bbox = L.latLngBounds([
+ [properties.extent[1], properties.extent[0]],
+ [properties.extent[3], properties.extent[2]],
+ ]);
+ } else {
+ zoom = 17;
+ }
+ let title = properties.name;
+ if (!title) {
+ title = address;
+ address = null;
+ }
+ let category = properties.osm_value;
+ if (['yes'].includes(category)) {
+ category = properties.osm_key;
+ }
+ category = category.replace('_', ' ');
+ return {
+ title,
+ latlng: L.latLng(feature.geometry.coordinates[1], feature.geometry.coordinates[0]),
+ bbox,
+ category,
+ address,
+ zoom,
+ icon: null,
+ };
+ });
+ return {results: places};
+ }
+});
+
+export {PhotonProvider};
diff --git a/src/lib/leaflet.control.search/providers/remoteBase.js b/src/lib/leaflet.control.search/providers/remoteBase.js
@@ -0,0 +1,40 @@
+import L from 'leaflet';
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+const BaseProvider = L.Class.extend({
+ options: {
+ maxResponses: null,
+ attribution: null,
+ delay: 500,
+ },
+
+ initialize: function(options) {
+ L.setOptions(this, options);
+ this.attribution = this.options.attribution;
+ },
+
+ getRequestLanguages: function(supportedLanguages, defaultLanguage) {
+ let languages = (navigator.languages ?? [])
+ .map((s) => s.split('-')[0])
+ .filter((value, index, arr) => arr.indexOf(value) === index)
+ .filter((lang) => supportedLanguages.includes(lang));
+ if (languages.length === 0) {
+ languages = [defaultLanguage];
+ }
+ return languages;
+ },
+
+ waitNoNewRequestsSent: async function() {
+ if (this.options.delay) {
+ const sleepPromise = this._sleep = sleep(this.options.delay);
+ await sleepPromise;
+ return this._sleep === sleepPromise;
+ }
+ return true;
+ }
+});
+
+export {BaseProvider};
diff --git a/src/lib/leaflet.control.search/style.css b/src/lib/leaflet.control.search/style.css
@@ -0,0 +1,141 @@
+.leaflet-search-container {
+ z-index: 100000;
+}
+
+.leaflet-search-placeholder {
+ height: 26px;
+ width: 300px;
+}
+
+.leaflet-search {
+ position: absolute;
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
+ border-radius: 4px;
+ margin-top: -26px;
+}
+
+.leaflet-search-input-wrapper {
+ box-sizing: border-box;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background-color: white;
+ height: 26px;
+ width: 300px;
+ display: flex;
+ flex-direction: row;
+}
+
+.leaflet-search-input {
+ font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;
+ font-size: 14px;
+ flex-grow: 1;
+ padding: 0 0 0 6px;
+ -moz-appearance:none;
+ -webkit-appearance:none;
+ border: none;
+ display: block;
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.leaflet-search-input::-webkit-search-decoration,
+.leaflet-search-input::-webkit-search-cancel-button,
+.leaflet-search-input::-webkit-search-results-button,
+.leaflet-search-input::-webkit-search-results-decoration {
+ display: none;
+}
+
+.leaflet-search-clear-button {
+ width: 14px;
+ margin: 0 6px 0 4px;
+ background-image: url("clear.svg");
+ background-repeat: no-repeat;
+ background-size: 12px;
+ background-position: 50% 50%;
+ cursor: pointer;
+}
+
+.hasresults .leaflet-search-input-wrapper {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.leaflet-search-results {
+ box-sizing: border-box;
+ list-style: none;
+ margin: 0;
+ width: 300px;
+ padding: 0;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+.leaflet-search-results li {
+ background-color: white;
+ border: 1px solid #aaa;
+ border-top: none;
+ padding: 8px 6px;
+ cursor: pointer;
+}
+
+.leaflet-search-results li:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+.leaflet-search-results .highlighted {
+ background-color: #eee;
+}
+
+.leaflet-search-results p {
+ margin: 0;
+}
+
+.leaflet-search-results .title {
+ font-size: 14px;
+}
+
+.leaflet-search-results .address {
+ color: #999;
+}
+
+.leaflet-search-results .category {
+ font-size: 14px;
+ color: #999;
+}
+.leaflet-search-results .icon {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-bottom: -3px;
+ margin-right: 2px;
+}
+
+.leaflet-search-error {
+ box-sizing: border-box;
+ width: 300px;
+ background-color: white;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border: 1px solid #aaa;
+ border-top: 0;
+ padding: 8px 6px;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.leaflet-search-results li.search-provider-attribution {
+ font-size: 11px;
+ color: #777;
+ text-align: right;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ cursor: default;
+}
+
+.leaflet-search-results li.search-provider-attribution a {
+ color: #777;
+}
+
+.icon-search {
+ background-image: url("button.svg");
+}
diff --git a/src/lib/leaflet.control.track-list/track-list.js b/src/lib/leaflet.control.track-list/track-list.js
@@ -725,7 +725,7 @@ L.Control.TrackList = L.Control.extend({
return;
}
const parentTrack = this.trackAddingPoint();
- const name = this.getNewPointName(parentTrack);
+ const name = e.suggested && this._map.suggestedPoint?.title || this.getNewPointName(parentTrack);
const newLatLng = e.latlng.wrap();
const marker = this.addPoint(parentTrack, {name: name, lat: newLatLng.lat, lng: newLatLng.lng});
this._markerLayer.addMarker(marker);
diff --git a/src/lib/leaflet.placemark/index.js b/src/lib/leaflet.placemark/index.js
@@ -0,0 +1,103 @@
+import L from 'leaflet';
+
+import '~/lib/leaflet.hashState/leaflet.hashState';
+
+import './style.css';
+
+const Placemark = L.Marker.extend({
+ initialize: function(latlng, title) {
+ this.title = title;
+ const icon = L.divIcon({
+ html: '<div class="lealfet-placemark-title"></div>',
+ className: 'leaflet-placemark',
+ });
+ L.Marker.prototype.initialize.call(this, latlng, {icon});
+ },
+
+ getTitle: function() {
+ return this.title;
+ },
+
+ onAdd: function(map) {
+ L.Marker.prototype.onAdd.call(this, map);
+ this.getElement().children[0].innerHTML = this.title;
+ this.on('click', this.onClick, this);
+ map.on('click', this.onMapClick, this);
+ map.suggestedPoint = {latlng: this.getLatLng(), title: this.title};
+ },
+
+ onRemove: function(map) {
+ this._map.off('move', this.onMapMove, this);
+ this._map.off('click', this.onMapClick, this);
+ this._map.suggestedPoint = null;
+ L.Marker.prototype.onRemove.call(this, map);
+ },
+
+ onClick: function() {
+ this._map.fire('click', {latlng: this.getLatLng(), suggested: true});
+ this._map.removeLayer(this);
+ },
+
+ onMapClick: function(e) {
+ if (!e.suggested) {
+ this._map.removeLayer(this);
+ }
+ },
+});
+
+L.Map.include({
+ showPlacemark: function(latlng, title) {
+ if (this._placemark) {
+ this.removeLayer(this._placemark);
+ }
+ this._placemark = new Placemark(latlng, title);
+ this.addLayer(this._placemark);
+ this.fire('placemarkshow');
+ this._placemark.on('remove', this.onPlacemarkRemove, this);
+ },
+
+ onPlacemarkRemove: function() {
+ this._placemark = null;
+ this.fire('placemarkhide');
+ },
+});
+
+const PlacemarkHashStateInterface = L.Class.extend({
+ includes: L.Mixin.HashState,
+
+ stateChangeEvents: ['placemarkshow', 'placemarkhide'],
+
+ initialize: function(map) {
+ this.map = map;
+ this.stateChangeEventsSource = 'map';
+ },
+
+ serializeState: function() {
+ const placemark = this.map._placemark;
+ if (!placemark) {
+ return null;
+ }
+ const latlng = placemark.getLatLng();
+ return [latlng.lat.toFixed(6), latlng.lng.toFixed(6), encodeURIComponent(placemark.getTitle())];
+ },
+
+ unserializeState: function(values) {
+ if (!values) {
+ return false;
+ }
+ const lat = parseFloat(values[0]);
+ const lng = parseFloat(values[1]);
+ const title = decodeURIComponent(values[2] ?? '');
+ if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
+ return false;
+ }
+ this.map.showPlacemark(L.latLng(lat, lng), title);
+ return true;
+ },
+});
+
+L.Map.include({
+ getPlacemarkHashStateInterface: function() {
+ return new PlacemarkHashStateInterface(this);
+ },
+});
diff --git a/src/lib/leaflet.placemark/marker.svg b/src/lib/leaflet.placemark/marker.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30">
+ <g stroke-width="4" stroke="#fff">
+ <path d="M0.5,15 l12,0" />
+ <path d="M29.5,15 l-12,0" />
+ <path d="M15,0.5 l0,12" />
+ <path d="M15,29.5 l0,-12" />
+ </g>
+ <g stroke-width="2" stroke="#000">
+ <path d="M1.5,15 l10,0" />
+ <path d="M28.5,15 l-10,0" />
+ <path d="M15,1.5 l0,10" />
+ <path d="M15,28.5 l0,-10" />
+ </g>
+</svg>
diff --git a/src/lib/leaflet.placemark/style.css b/src/lib/leaflet.placemark/style.css
@@ -0,0 +1,20 @@
+.leaflet-placemark {
+ background-image: url('marker.svg');
+ background-size: 30px 30px;
+ width: 30px !important;
+ height: 30px !important;
+ margin-left: -15px !important;
+ margin-top: -15px !important;
+}
+
+.lealfet-placemark-title {
+ position: relative;
+ display: inline-block;
+ background-color: rgba(255, 255, 255, 0.4);
+ padding: 0 2px;
+ left: 22px;
+ top: -8px;
+ white-space: nowrap;
+ font-weight: bold;
+}
+
diff --git a/test/test_search_coordinates.js b/test/test_search_coordinates.js
@@ -0,0 +1,173 @@
+import {CoordinatesProvider} from '~/lib/leaflet.control.search/providers/coordinates';
+
+const coords = new CoordinatesProvider();
+
+suite('CoordinatesProvider - parse good coordinates');
+[
+ // Well formatted
+ ['55 37', ['55° 37°', '37° 55°']],
+ ['55° 55°', ['55° 55°']],
+ ['55.94920° 36.82205°', ['55.9492° 36.82205°', '36.82205° 55.9492°']],
+ ['N 55.93382° E 36.93604°', ['N 55.93382° E 36.93604°']],
+ ['N 55°52.981′ E 36°59.540′', ['N 55°52.981′ E 36°59.54′']],
+ ['N 55°49′02.95″ E 37°03′09.95″', ['N 55°49′2.95″ E 37°3′9.95″']],
+ ['-55° 37°', ['-55° 37°', '37° -55°']],
+ ['-55.94920 36.82205', ['-55.9492° 36.82205°', '36.82205° -55.9492°']],
+ ['S 55.93382° E 36.93604°', ['S 55.93382° E 36.93604°']],
+ ['S 55°52.981′ E 36°59.540′', ['S 55°52.981′ E 36°59.54′']],
+ ['S 55°49′02.95″ E 37°03′09.95″', ['S 55°49′2.95″ E 37°3′9.95″']],
+ ['55° -37°', ['55° -37°', '-37° 55°']],
+ ['55.94920 -36.82205', ['55.9492° -36.82205°', '-36.82205° 55.9492°']],
+ ['N 55.93382° W 36.93604°', ['N 55.93382° W 36.93604°']],
+ ['N 55°52.981′ W 36°59.540′', ['N 55°52.981′ W 36°59.54′']],
+ ['N 55°49′02.95″ W 37°03′09.95″', ['N 55°49′2.95″ W 37°3′9.95″']],
+
+ // swap lat/lon
+ ['S 1 2 3 E 4 5 6', ['S 1°2′3″ E 4°5′6″']],
+ ['E 1 2 3 S 4 5 6', ['S 4°5′6″ E 1°2′3″']],
+ ['N 1 2 E 4 5', ['N 1°2′ E 4°5′']],
+ ['E 1 2 N 4 5', ['N 4°5′ E 1°2′']],
+ ['N 1 E 4', ['N 1° E 4°']],
+ ['E 1 N 4', ['N 4° E 1°']],
+
+ // minimal whitespaces, placing hemispheres
+ ['N55.93382 E36.93604', ['N 55.93382° E 36.93604°']],
+ ['55.93382N36.93604E', ['N 55.93382° E 36.93604°']],
+ ['N55.93382,36.93604E', ['N 55.93382° E 36.93604°']],
+ ['S55°52.981′ W36°59.540′', ['S 55°52.981′ W 36°59.54′']],
+ ['S55°52.981′,36°59.540′W', ['S 55°52.981′ W 36°59.54′']],
+ ['55°52.981′S36°59.540′W', ['S 55°52.981′ W 36°59.54′']],
+ ['36°59.540′W55°52.981′S', ['S 55°52.981′ W 36°59.54′']],
+ ['N55°49′02.95″,W37°03′09.95″', ['N 55°49′2.95″ W 37°3′9.95″']],
+ ['55°49′02.95″N37°03′09.95″W', ['N 55°49′2.95″ W 37°3′9.95″']],
+ ['N55°49′02.95″,37°03′09.95″W', ['N 55°49′2.95″ W 37°3′9.95″']],
+
+ // types of hemispheres
+ ['s 55.93382 w 36.93604', ['S 55.93382° W 36.93604°']],
+ ['55.93382 юш 36.93604 зд', ['S 55.93382° W 36.93604°']],
+ ['55.93382 ю ш 36.93604 з д', ['S 55.93382° W 36.93604°']],
+ ['55.93382 ю. ш. 36.93604 з. д.', ['S 55.93382° W 36.93604°']],
+ ['55.93382 с.ш. 36.93604 в.д.', ['N 55.93382° E 36.93604°']],
+
+ // margin values
+ ['0 0', ['0° 0°']],
+ ['-0 -0', ['0° 0°']],
+ ['N 0 E 0', ['N 0° E 0°']],
+ ['N 0 0 E 0 0', ['N 0°0′ E 0°0′']],
+ ['N 0 0 0 E 0 0 0', ['N 0°0′0″ E 0°0′0″']],
+ ['90 180', ['90° 180°']],
+ ['-90 -180', ['-90° -180°']],
+ ['N 90 E180', ['N 90° E 180°']],
+ ['N 89 59 E179 59', ['N 89°59′ E 179°59′']],
+ ['N 89 59 59 E179 59 59', ['N 89°59′59″ E 179°59′59″']],
+
+ // floating point
+ ['55,2 37,6', ['55.2° 37.6°', '37.6° 55.2°']],
+ ['N 55,93382° E 36,93604°', ['N 55.93382° E 36.93604°']],
+ ['S 55°52,981′ E 36°59,540′', ['S 55°52.981′ E 36°59.54′']],
+ ['S 55°49′02,95″ E 37°03′09,95″', ['S 55°49′2.95″ E 37°3′9.95″']],
+ ['55.2,37.6', ['55.2° 37.6°', '37.6° 55.2°']],
+
+ // junk
+ ['55.94920-36.82205', ['55.9492° 36.82205°', '36.82205° 55.9492°']],
+ ['- 55.94920- 36.82205-', ['55.9492° 36.82205°', '36.82205° 55.9492°']],
+
+ ['N 43º 12 13 E 58º 14 15', ['N 43°12′13″ E 58°14′15″']],
+ ['N 43о 12 13 E 58О 14 15', ['N 43°12′13″ E 58°14′15″']], // rus
+ ['N 43o 12 13 E 58O 14 15', ['N 43°12′13″ E 58°14′15″']], // lat
+
+ // without hemispheres
+ ['1 2.8 3 4.9', ['N 1°2.8′ E 3°4.9′', 'N 3°4.9′ E 1°2.8′']],
+ ['1 2 3.8 4 5 6.9', ['N 1°2′3.8″ E 4°5′6.9″', 'N 4°5′6.9″ E 1°2′3.8″']],
+ ['1 2 1 2', ['N 1°2′ E 1°2′']],
+ ['1 2 3 1 2 3', ['N 1°2′3″ E 1°2′3″']],
+].forEach(function([query, expectedResult]) {
+ test(`Parse ${query}`, async function() {
+ assert.isTrue(coords.isOurQuery(query));
+ const result = await coords.search(query);
+ assert.notProperty(result, 'error');
+ assert.property(result, 'results');
+ const titles = result.results.map((item) => item.title);
+ assert.deepEqual(expectedResult, titles);
+ });
+});
+
+suite('CoordinatesProvider - not coordinates');
+
+[
+ '',
+ 'aaa',
+ '111',
+ '1.23',
+ '-1.23',
+ '1 a',
+ '55 a 37',
+ '55 37 a',
+ 'a 55 37',
+ '55a37',
+ '55 37a',
+ 'a55 37',
+ '8 мая 122/43',
+ 'wee',
+].forEach(function(query) {
+ test(`Not a coordinates string ${query}`, async function() {
+ assert.isFalse(coords.isOurQuery(query));
+ });
+});
+
+suite('CoordinatesProvider - invalid coordinates');
+
+[
+ '1 2 3',
+ '1 2 3 4 5',
+ 'n1 2 3 e 4 5 6 w',
+ 'N 90 1 E 1 1',
+ 'N 1 1 E 180 1',
+ 'N 1 60 1 E 1 1',
+ 'N 1 1 1 E 60 1',
+ 'N 90 1 0 E 1 1 1',
+ 'N 1 1 1 E 180 1 0',
+ '91 92',
+ '89 181',
+ 'N 91 e 180',
+ 'N 90 e 181',
+ 'N 1 60 e 2 3',
+ 'N 1 1 e 2 60',
+ 'N 1 1 60 e 2 2 2',
+ 'N 1 1 1 e 2 2 60',
+ '55.2,37,6',
+ 'n1 2 e 3 4 5 6',
+ 'N1 2 3 4 E 4 5 6 7',
+ 'N -1 E 2',
+ 'N 1 E -2',
+ 'N -1 2 E 3 4',
+ 'N 1 -2 E 3 4',
+ 'N 1 2 E -3 4',
+ 'N 1 2 E 3 -4',
+ 'N -1 2 3 E 4 5 6',
+ 'N 1 -2 3 E 4 5 6',
+ 'N 1 2 -3 E 4 5 6',
+ 'N 1 2 3 E -4 5 6',
+ 'N 1 2 3 E 4 -5 6',
+ 'N 1 2 3 E 4 5 -6',
+ 'N 1.1 2 3 E 4 5 6',
+ 'N 1 2.1 3 E 4 5 6',
+ 'N 1 2 3 E 4.1 5 6',
+ 'N 1 2 3 E 4 5.1 6',
+ 'N 1 2',
+ '1 E 2',
+ '1 2 N',
+ 'N 1 2 3 4',
+ '1 2 E 3 4',
+ '1 2 3 4 E',
+ 'N 1 2 3 4 5 6',
+ '1 2 3 E 4 5 6',
+ '1 2 3 4 5 6 E',
+].forEach(function(query) {
+ test(`Invalid coordinates ${query}`, async function() {
+ assert.isTrue(coords.isOurQuery(query));
+ const result = await coords.search(query);
+ assert.notProperty(result, 'results');
+ assert.propertyVal(result, 'error', 'Invalid coordinates');
+ });
+});
diff --git a/test/test_search_links.js b/test/test_search_links.js
@@ -0,0 +1,151 @@
+import {LinksProvider} from '~/lib/leaflet.control.search/providers/links';
+
+const links = new LinksProvider();
+
+suite('LinksProvider - parsing valid links');
+[
+ [
+ 'https://www.google.com/maps/@49.1906435,16.5429962,14z',
+ 'Google map view',
+ {lat: 49.1906435, lng: 16.5429962},
+ 14,
+ ],
+ [
+ 'https://yandex.ru/maps/10509/brno/?ll=16.548629%2C49.219896&z=14',
+ 'Yandex map view',
+ {lat: 49.219896, lng: 16.548629},
+ 14,
+ ],
+ ['https://yandex.ru/maps/?ll=16.548629%2C49.219896&z=14', 'Yandex map view', {lat: 49.219896, lng: 16.548629}, 14],
+ ['https://www.openstreetmap.org/#map=14/49.2199/16.5486', 'OpenStreetMap view', {lat: 49.2199, lng: 16.5486}, 14],
+ [
+ 'https://en.mapy.cz/turisticka?x=16.5651083&y=49.2222502&z=14',
+ 'Mapy.cz view',
+ {lat: 49.2222502, lng: 16.5651083},
+ 14,
+ ],
+ [
+ 'https://www.openstreetmap.org/search?query=%D0%BD%D0%B5%D1%80%D1%81%D0%BA%D0%BE%D0%B5%20%D0%BE%D0%B7%D0%B5%D1%80%D0%BE#map=17/55.56647/38.87365', // eslint-disable-line max-len
+ 'OpenStreetMap view',
+ {lat: 55.56647, lng: 38.87365},
+ 17,
+ ],
+ [
+ 'https://www.google.com/maps/place/Nerskoye+Ozero/@56.0836099,37.3849634,16z/data=!3m1!4b1!4m5!3m4!1s0x46b5178a0be6c5b1:0xb13c53547e1d966d!8m2!3d56.0826073!4d37.388256', // eslint-disable-line max-len
+ 'Google map - Nerskoye Ozero',
+ {lat: 56.0836099, lng: 37.3849634},
+ 16,
+ ],
+ [
+ 'https://www.google.ru/maps/place/%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B0,+%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D1%8F/@55.5807481,36.8251331,9z/data=!3m1!4b1!4m5!3m4!1s0x46b54afc73d4b0c9:0x3d44d6cc5757cf4c!8m2!3d55.755826!4d37.6173', // eslint-disable-line max-len
+ 'Google map - Москва, Россия',
+ {lat: 55.5807481, lng: 36.8251331},
+ 9,
+ ],
+ [
+ 'https://www.google.com/maps/place/Vav%C5%99ineck%C3%A1,+514+01+Jilemnice/@50.6092632,15.5023689,17z/data=!3m1!4b1!4m5!3m4!1s0x470ebf1b56d0fca9:0xddb7e19a6b1f5828!8m2!3d50.6092632!4d15.5045576', // eslint-disable-line max-len
+ 'Google map - Vavřinecká, 514 01 Jilemnice',
+ {lat: 50.6092632, lng: 15.5023689},
+ 17,
+ ],
+ [
+ 'https://www.google.com/maps?q=loc:49.1817864,16.5771214',
+ 'Google map view',
+ {lat: 49.1817864, lng: 16.5771214},
+ 17,
+ ],
+ [
+ 'https://maps.google.com/maps?q=49.223089,16.554547&ll=49.223089,16.554547&z=16',
+ 'Google map view',
+ {lat: 49.223089, lng: 16.554547},
+ 17,
+ ],
+ [
+ 'https://www.google.com/maps?q=loc:-49.1817864,-16.5771214',
+ 'Google map view',
+ {lat: -49.1817864, lng: -16.5771214},
+ 17,
+ ],
+ [
+ 'https://www.google.ru/maps?q=loc:-49.1817864,-16.5771214',
+ 'Google map view',
+ {lat: -49.1817864, lng: -16.5771214},
+ 17,
+ ],
+ [
+ 'https://www.google.com/maps/@49.1906435,16.5429962,14z?q=loc:49.1817864,16.5771214',
+ 'Google map view',
+ {lat: 49.1906435, lng: 16.5429962},
+ 14,
+ ],
+ [
+ 'https://www.google.com/maps/place/Nerskoye+Ozero/@56.0836099,37.3849634,16z/data=!3m1!4b1!4m5!3m4!1s0x46b5178a0be6c5b1:0xb13c53547e1d966d!8m2!3d56.0826073!4d37.388256?q=loc:-49.1817864,-16.5771214', // eslint-disable-line max-len
+ 'Google map - Nerskoye Ozero',
+ {lat: 56.0836099, lng: 37.3849634},
+ 16,
+ ],
+
+ ['https://nakarte.me/#m=11/49.44893/16.59897&l=O', 'Nakarte view', {lat: 49.44893, lng: 16.59897}, 11],
+ ['https://nakarte.me/#l=O&m=11/49.44893/16.59897', 'Nakarte view', {lat: 49.44893, lng: 16.59897}, 11],
+ ['https://example.com/#l=O&m=11/49.44893/16.59897', 'Nakarte view', {lat: 49.44893, lng: 16.59897}, 11],
+].forEach(function([query, expectedTitle, expectedCoordinates, expectedZoom]) {
+ test(`Parse link ${query}`, async function() {
+ assert.isTrue(links.isOurQuery(query));
+ const result = await links.search(query);
+ assert.notProperty(result, 'error');
+ assert.property(result, 'results');
+ assert.lengthOf(result.results, 1);
+ const item = result.results[0];
+ const resultGot = {
+ title: item.title,
+ latlng: {lat: item.latlng.lat, lng: item.latlng.lng},
+ zoom: item.zoom,
+ };
+ const resultExpected = {title: expectedTitle, latlng: expectedCoordinates, zoom: expectedZoom};
+ assert.deepEqual(resultExpected, resultGot);
+ });
+});
+
+suite('LinksProvider - parse invalid links');
+[
+ ['https://', 'Invalid link'],
+ ['http://', 'Invalid link'],
+ ['https://example.com', 'Unsupported link'],
+ ['https://yandex.ru/maps/-/CCQlZLeFHA', 'Invalid coordinates in Yandex link'],
+ ['https://yandex.ru/maps/', 'Invalid coordinates in Yandex link'],
+ ['https://yandex.ru/maps/10509/brno/?ll=16.548629%2C149.219896&z=14', 'Invalid coordinates in Yandex link'],
+ ['https://en.mapy.cz/s/kofosuhuda', 'Invalid coordinates in Mapy.cz link'],
+ ['https://en.mapy.cz/turisticka?x=16.5651083&y=49.2222502&z=', 'Invalid coordinates in Mapy.cz link'],
+ ['https://www.google.com/maps', 'Invalid coordinates in Google link'],
+ ['https://goo.gl/maps/igLWhY3jFpifZhTk6', 'Unsupported link'],
+ ['https://www.google.com/maps/@99.1906435,16.5429962,14z', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps/@49.1906435,190.5429962,14z', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps/@49.1906435,19.5429962,45z', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:49.1817864,', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:49.1817864', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:4', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:99.1817864,16.5771214', 'Invalid coordinates in Google link'],
+ ['https://www.google.com/maps?q=loc:49.1817864,196.5771214', 'Invalid coordinates in Google link'],
+ ['https://nakarte.me/', 'Invalid coordinates in Nakarte link'],
+ ['https://nakarte.me/#l=O', 'Invalid coordinates in Nakarte link'],
+ ['https://example.com/#l=O&m=11/49.44893/', 'Unsupported link'],
+ ['https://example.com/#l=O&m=99/49.44893/52.5547', 'Unsupported link'],
+].forEach(function([query, expectedError]) {
+ test(`Invalid link ${query}`, async function() {
+ assert.isTrue(links.isOurQuery(query));
+ const result = await links.search(query);
+ assert.notProperty(result, 'results');
+ assert.propertyVal(result, 'error', expectedError);
+ });
+});
+
+suite('LinksProvider - not links');
+['abc', 'http:/', 'https:/', 'https:/'].forEach(function(query) {
+ test(`Not a link ${query}`, function() {
+ assert.isFalse(links.isOurQuery(query));
+ });
+});
diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js
@@ -134,6 +134,15 @@ const loaders = [
test: /\.(html)(\?.*)?$/u,
loader: 'raw-loader',
},
+ {
+ test: /\.csv$/u,
+ loader: 'csv-loader',
+ options: {
+ dynamicTyping: true,
+ header: true,
+ skipEmptyLines: true,
+ },
+ },
...(isProduction || isDevelopment
? [
diff --git a/yarn.lock b/yarn.lock
@@ -2973,6 +2973,14 @@ csso@^4.0.2:
dependencies:
css-tree "1.0.0-alpha.37"
+csv-loader@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/csv-loader/-/csv-loader-3.0.3.tgz#eba02221a6f3ceb1447140d3bae26f4032b4af52"
+ integrity sha512-JMr83kH2sOFKbRO95fAQV1fLEc1Chx1osJpU7Gd5ZQhmXrsQN479P08sDuyZoO5LMiJ8IsR72Xtl/nSA7rh4Lw==
+ dependencies:
+ loader-utils "^2.0.0"
+ papaparse "^5.2.0"
+
custom-event@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
@@ -6934,6 +6942,11 @@ pako@^1.0.10, pako@~1.0.2, pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732"
integrity sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==
+papaparse@^5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.2.0.tgz#97976a1b135c46612773029153dc64995caa3b7b"
+ integrity sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA==
+
parallel-transform@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"