nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

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:
M.eslintrc | 3+++
Mpackage.json | 1+
Msrc/App.js | 26++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/button.svg | 10++++++++++
Asrc/lib/leaflet.control.search/clear.svg | 8++++++++
Asrc/lib/leaflet.control.search/control.html | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/index.js | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/coordinates.js | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/index.js | 13+++++++++++++
Asrc/lib/leaflet.control.search/providers/links.js | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/mapycz/categories.csv | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/mapycz/icons.json | 675+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/mapycz/index.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/photon.js | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/providers/remoteBase.js | 40++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.search/style.css | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/leaflet.control.track-list/track-list.js | 2+-
Asrc/lib/leaflet.placemark/index.js | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.placemark/marker.svg | 15+++++++++++++++
Asrc/lib/leaflet.placemark/style.css | 20++++++++++++++++++++
Atest/test_search_coordinates.js | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/test_search_links.js | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwebpack/webpack.config.js | 9+++++++++
Myarn.lock | 13+++++++++++++
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>&nbsp;&nbsp;<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"