total rewrite, rewritten in es6, added tests, improved decoupling

This commit is contained in:
Stephan Meijer
2016-12-17 01:23:48 +01:00
parent d69c0119b9
commit 4cc5e998da
54 changed files with 6126 additions and 643 deletions

22
.babelrc Normal file
View File

@@ -0,0 +1,22 @@
{
"presets": ["es2015", "stage-0", "power-assert"],
"plugins": [
["transform-react-jsx", { "pragma": "preact.h"}],
"transform-async-to-generator",
["babel-plugin-espower", {
"embedAst": true,
"patterns": [
"t.truthy(value, [message])",
"t.falsy(value, [message])",
"t.true(value, [message])",
"t.false(value, [message])",
"t.is(value, expected, [message])",
"t.not(value, expected, [message])",
"t.deepEqual(value, expected, [message])",
"t.notDeepEqual(value, expected, [message])",
"t.regex(contents, regex, [message])",
"t.notRegex(contents, regex, [message])"
]
}]
]
}

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
# @see http://editorconfig.org/
# This is the top-most .editorconfig file; do not search in parent directories.
root = true
# All files.
[*]
end_of_line = LF
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

2
.env Normal file
View File

@@ -0,0 +1,2 @@
BING_API_KEY=AtUDjSVEBxo8BwgYUPdfnzHpznaYwDdjjS27jyFDj18nhTUDUjrhc0NwMndZvrXs
GOOGLE_API_KEY=AIzaSyDigZ5WMPoTj_gnkUn3p1waYPDa5oE8WOw

55
.eslintrc.yml Normal file
View File

@@ -0,0 +1,55 @@
extends: "airbnb"
env:
browser: true
parser: "babel-eslint"
parserOptions:
ecmaVersion: 6
ecmaFeatures:
experimentalObjectRestSpread: true
jsx: true
sourceType: "module"
rules:
# we need this to test leaflet private vars
no-underscore-dangle: "off"
# force else and catch to a new line
brace-style:
- "error"
- "stroustrup"
- allowSingleLine: true
quote-props:
- "error"
- "consistent-as-needed"
- keywords: true
class-methods-use-this: "off"
import/no-extraneous-dependencies:
- "error"
- devDependencies:
- "**/*.spec.js"
- "**/test_helpers/**/*.js"
- "example/**/*.js"
react/jsx-filename-extension:
- "error"
- extensions:
- ".js"
react/prop-types: "off"
jsx-a11y/anchor-has-content: "off"
globals:
L: false
fetch: false
document: false
location: false
settings:
react:
pragma: "preact"

22
.gitattributes vendored
View File

@@ -1,22 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto
# Custom for Visual Studio
*.cs diff=csharp
*.sln merge=union
*.csproj merge=union
*.vbproj merge=union
*.fsproj merge=union
*.dbproj merge=union
# Standard to msysgit
*.doc diff=astextplain
*.DOC diff=astextplain
*.docx diff=astextplain
*.DOCX diff=astextplain
*.dot diff=astextplain
*.DOT diff=astextplain
*.pdf diff=astextplain
*.PDF diff=astextplain
*.rtf diff=astextplain
*.RTF diff=astextplain

3
.gitignore vendored
View File

@@ -165,3 +165,6 @@ pip-log.txt
*.sublime-project *.sublime-project
*.sublime-workspace *.sublime-workspace
.idea .idea
dist/
lib/
node_modules/

1
.npmignore Normal file
View File

@@ -0,0 +1 @@
src/

0
assets/css/geosearch.css Normal file
View File

View File

@@ -1,34 +1,32 @@
.displayNone { .geosearch.leaflet-bar form,
display: none; .geosearch.leaflet-bar .message {
display: none;
} }
.leaflet-control-geosearch { .geosearch.leaflet-bar.active form {
position: relative; display: block;
} }
.leaflet-control-geosearch a { .geosearch a.leaflet-bar-part {
-webkit-border-radius: 4px; border-radius: 4px;
border-radius: 4px;
border-bottom: none; border-bottom: none;
}
.leaflet-control-geosearch a.glass {
background-image: url(../img/geosearch.png); background-image: url(../img/geosearch.png);
background-position: center center;
background-size: 100% 100%; background-size: 100% 100%;
} }
.leaflet-control-geosearch a.spinner { .geosearch.pending a.leaflet-bar-part {
background-image: url(../img/spinner.gif); background-image: url(../img/spinner.gif);
background-position: 50% 50%; background-size: 12px 12px;
} }
.leaflet-control-geosearch a.alert { .geosearch.error a.leaflet-bar-part {
background-image: url(../img/alert.png); background-image: url(../img/alert.png);
background-size: 64% 64%; background-size: 18px 18px;
} }
.leaflet-control-geosearch a:hover { .leaflet-control-geosearch {
border-bottom: none; position: relative;
} }
.leaflet-control-geosearch form { .leaflet-control-geosearch form {
@@ -36,17 +34,12 @@
top: 0; top: 0;
left: 22px; left: 22px;
box-shadow: 0 1px 7px rgba(0, 0, 0, 0.65); box-shadow: 0 1px 7px rgba(0, 0, 0, 0.65);
-webkit-border-radius: 4px; border-radius: 0 4px 4px 0;
border-radius: 0px 4px 4px 0px;
z-index: -1; z-index: -1;
background: #FFF; background: #FFF;
height: 26px; height: auto;
padding: 0 6px 0 6px; margin: 0;
} padding: 0;
.leaflet-touch .leaflet-control-geosearch form {
padding: 2px 6px 2px 8px;
line-height: 28px;
} }
.leaflet-control-geosearch form input { .leaflet-control-geosearch form input {
@@ -56,7 +49,9 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
font-size: 12px; font-size: 12px;
margin-top: 5px; height: 30px;
border-radius: 0 4px 4px 0;
} }
.leaflet-control-geosearch .message { .leaflet-control-geosearch .message {

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 699 B

After

Width:  |  Height:  |  Size: 699 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

54
example/Layout.js Normal file
View File

@@ -0,0 +1,54 @@
import preact, { Component } from 'preact';
import Search from './Search';
class Layout extends Component {
constructor(props) {
super(props);
this.state = {
hash: window.location.hash.slice(1),
};
}
componentDidMount() {
window.addEventListener('hashchange', this.changePage, false);
}
componentWillUnmount() {
window.removeEventListener('hashchange', this.changePage, false);
}
changePage = () => {
this.setState({
hash: window.location.hash.slice(1),
});
};
render() {
const { pages } = this.props;
const { hash } = this.state;
const page = pages.find(p => p.slug === (hash || 'search'));
return (
<div>
<div className="header">
<h1>{`GeoSearch / ${page.title}`}</h1>
<ul>
{pages.map((p, idx) => (
<li key={idx} className={p.slug === hash ? 'active' : ''}>
<a href={`#${p.slug}`}>{p.title}</a>
</li>
))}
</ul>
</div>
<div className={`content ${hash}`}>
{page && page.view()}
</div>
</div>
);
}
}
export default Layout;

55
example/Map.js Normal file
View File

@@ -0,0 +1,55 @@
import preact, { Component } from 'preact';
import merge from 'lodash.merge';
import { GeoSearchControl, OpenStreetMapProvider, Provider as BaseProvider } from '../src';
const L = window.L;
// eslint-disable-next-line no-confusing-arrow
const ensureInstance = Provider => Provider instanceof BaseProvider ? Provider : new Provider();
const mapOptions = () => ({
layers: [
new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}),
],
center: new L.LatLng(53.2, 5.8),
zoom: 12,
});
class Map extends Component {
componentDidMount() {
const { options, Provider } = this.props;
this.map = new L.Map(this.container, merge(mapOptions(), options));
const provider = (Provider) ? ensureInstance(Provider) : new OpenStreetMapProvider();
this.searchControl = new GeoSearchControl({
provider,
}).addTo(this.map);
}
componentDidUpdate() {
const Provider = this.props.Provider || OpenStreetMapProvider;
this.searchControl.options.provider = ensureInstance(Provider);
}
componentWillUnmount() {
this.map.remove();
}
bindContainer = (container) => {
this.container = container;
};
render() {
const { style } = this.props;
return (
<div className="leaflet-map" style={style} ref={this.bindContainer} />
);
}
}
export default Map;

103
example/Search.js Normal file
View File

@@ -0,0 +1,103 @@
import preact, { Component } from 'preact';
import debounce from 'lodash.debounce';
import * as providers from '../src/providers';
import SearchResults from './SearchResults';
const specialKeys = ['ArrowDown', 'ArrowUp', 'Escape'];
class Search extends Component {
constructor(props) {
super(props);
const Provider = providers[`${props.provider}Provider`] ||
providers.OpenStreetMapProvider;
this.provider = new Provider();
}
onSubmit = async (event) => {
event.preventDefault();
const { query } = this.state;
const results = await this.provider.search({ query });
this.setState({
results,
});
};
onKeyUp = (event) => {
if (specialKeys.includes(event.code)) {
return;
}
const query = event.target.value;
this.setState({
query,
});
this.autoSearch(event);
};
onKeyDown = (event) => {
if (event.code === 'Escape') {
this.reset();
return;
}
if (event.code !== 'ArrowDown' && event.code !== 'ArrowUp') {
return;
}
event.preventDefault();
const { selected = -1, results } = this.state;
const max = results.length - 1;
// eslint-disable-next-line no-bitwise
const next = (event.code === 'ArrowDown') ? ~~selected + 1 : ~~selected - 1;
// eslint-disable-next-line no-nested-ternary
const idx = (next < 0) ? max : (next > max) ? 0 : next;
this.setState({
selected: idx,
query: results[idx].label,
});
};
autoSearch = debounce((event) => {
this.onSubmit(event);
}, 250);
reset() {
this.setState({
results: [],
selected: -1,
query: '',
});
}
render() {
const { results, selected, query } = this.state;
return (
<div className="search">
<form onSubmit={this.onSubmit}>
<input
onKeyUp={this.onKeyUp}
onKeyDown={this.onKeyDown}
type="text"
placeholder="search"
value={query}
/>
</form>
{results &&
<SearchResults results={results} selected={selected} />
}
</div>
);
}
}
export default Search;

11
example/SearchResults.js Normal file
View File

@@ -0,0 +1,11 @@
import preact, { Component } from 'preact';
const SearchResults = ({ results = [], selected }) => (
<div className="results">
{results.map((result, idx) => (
<div className={idx === selected ? 'active' : ''}>{result.label}</div>
))}
</div>
);
export default SearchResults;

View File

@@ -24,7 +24,7 @@
}); });
new L.Control.GeoSearch({ new L.Control.GeoSearch({
provider: new L.GeoSearch.Provider.Google() provider: new L.GeoSearch.Provider.Google()
}).addTo(map); }).addTo(map);
</script> </script>

18
example/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Leaflet.GeoSearch / Google Provider</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans|Roboto" rel="stylesheet">
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.css" />
<script src="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.js"></script>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="/assets/css/leaflet.css" />
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/dist/bundle.js" charset="utf-8"></script>
</body>
</html>

61
example/main.js Normal file
View File

@@ -0,0 +1,61 @@
import preact, { render } from 'preact';
import Layout from './Layout';
import Search from './Search';
import Map from './Map';
import * as providers from '../src/providers';
// import css to enable hot reloading
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable global-require */
require('./index.html');
require('./style.css');
require('../assets/css/leaflet.css');
}
const pages = [
{
slug: 'search',
title: 'Search',
view: () => (<Search />),
},
{
slug: 'openstreetmap',
title: 'OpenStreetMap',
view: () => (<Map Provider={providers.OpenStreetMapProvider} />),
},
{
slug: 'google',
title: 'Google',
view: () => {
const Provider = new providers.GoogleProvider({ params: {
key: 'AIzaSyDigZ5WMPoTj_gnkUn3p1waYPDa5oE8WOw',
} });
return <Map Provider={Provider} />;
},
},
{
slug: 'bing',
title: 'Bing',
view: () => {
const Provider = new providers.BingProvider({ params: {
key: 'AtUDjSVEBxo8BwgYUPdfnzHpznaYwDdjjS27jyFDj18nhTUDUjrhc0NwMndZvrXs',
} });
return <Map Provider={Provider} />;
},
},
{
slug: 'esri',
title: 'Esri',
view: () => (<Map Provider={providers.EsriProvider} />),
},
];
if (location.hash === '') {
location.hash = 'search';
}
render((
<Layout pages={pages} />
), document.getElementById('app'));

View File

@@ -23,9 +23,9 @@
center: new L.LatLng(53.2, 5.8), zoom: 12 center: new L.LatLng(53.2, 5.8), zoom: 12
}); });
new L.Control.GeoSearch({ new L.Control.GeoSearch({
provider: new L.GeoSearch.Provider.Nokia() provider: new L.GeoSearch.Provider.Nokia()
}).addTo(map); }).addTo(map);
</script> </script>
</body> </body>

View File

@@ -6,26 +6,27 @@
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.css" /> <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.css" />
<script src="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.js"></script> <script src="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.js"></script>
<script src="../src/js/l.control.geosearch.js"></script> <script src="../dist/geosearch.js"></script>
<script src="../src/js/l.geosearch.provider.openstreetmap.js"></script> <link rel="stylesheet" href="../assets/css/leaflet.css" />
<link rel="stylesheet" href="../src/css/l.geosearch.css" />
</head> </head>
<body> <body>
<div id="map" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></div> <div id="map" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></div>
<script type="text/javascript"> <script type="text/javascript">
const { GeoSearchControl, OpenStreetMapProvider } = window.GeoSearch;
var osmTileUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const osmTileUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
var basemap = new L.TileLayer(osmTileUrl, {maxZoom: 18}); const basemap = new L.TileLayer(osmTileUrl, { maxZoom: 18 });
var map = new L.Map('map', { const map = new L.Map('map', {
layers: [basemap], layers: [basemap],
center: new L.LatLng(53.2, 5.8), zoom: 12 center: new L.LatLng(53.2, 5.8), zoom: 12
}); });
new L.Control.GeoSearch({ const provider = new OpenStreetMapProvider();
provider: new L.GeoSearch.Provider.OpenStreetMap() new GeoSearchControl({
}).addTo(map); provider
}).addTo(map);
</script> </script>
</body> </body>

128
example/style.css Normal file
View File

@@ -0,0 +1,128 @@
html, body {
font-family: 'Open Sans', sans-serif;
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
#app, #app > .container {
height: 100%;
position: relative;
}
#app .header,
.content.search {
width: 80%;
margin: 0 auto;
position: relative;
}
#app .header {
height: 100px;
}
h1 {
font-family: 'Roboto', sans-serif;
font-size: 32px;
font-weight: 300;
line-height: 64px;
margin: 0;
padding: 0;
}
ul {
list-style: none;
display: block;
overflow: hidden;
margin: 0;
padding: 0;
line-height: 32px
}
ul li {
float: left;
display: block;
cursor: pointer;
}
ul li a {
text-decoration: none;
color: inherit;
display: block;
width: 100%;
height: 100%;
padding: 0 24px;
}
ul li.active {
border-bottom: 4px solid #2196f3;
}
ul li:hover {
border-bottom: 4px solid #00bcd4;
}
.content {
position: absolute;
display: block;
width: 100%;
height: calc(100% - 100px);
}
.results > * {
border: 1px solid transparent;
line-height: 32px;
padding: 0 32px;
}
.results > *:hover,
.results > .active {
background-color: #f8f8f8;
border-color: #c6c6c6;
}
form {
position: relative;
margin: 32px 0;
background-color: #fff;
vertical-align: top;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08);
transition: box-shadow 200ms cubic-bezier(0.4, 0.0, 0.2, 1);
}
form.active, form:hover {
box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.08);
}
input {
border: none;
padding: 0;
margin: 0;
width: 100%;
outline: none;
font: 16px arial, sans-serif;
line-height: 48px;
height: 48px;
text-indent: 18px;
}
form button {
outline: none;
display: block;
position: absolute;
right: 0;
height: 100%;
width: 100px;
background-color: #f8f8f8;
border: 1px solid #c6c6c6;
}
.leaflet-map {
position: absolute;
display: block;
width: 100%;
height: 100%;
}

View File

@@ -1,24 +1,36 @@
{ {
"name": "leaflet-geosearch", "name": "leaflet-geosearch",
"version": "1.1.0", "version": "2.0.0-rc.1",
"publishConfig": {
"tag": "next"
},
"description": "Adds support for address lookup (a.k.a. geocoding / geoseaching) to Leaflet.", "description": "Adds support for address lookup (a.k.a. geocoding / geoseaching) to Leaflet.",
"main": "src/js/l.control.geosearch.js", "main": "lib/index.js",
"scripts": {
"build:commonjs": "babel src --out-dir lib --ignore *.spec.js",
"build:umd": "cross-env NODE_ENV=development webpack",
"build:umd:min": "cross-env NODE_ENV=production webpack",
"build:watch": "npm run build:umd -- --watch",
"build": "npm run clean && npm run build:commonjs && npm run build:umd && npm run build:umd:min",
"clean": "rimraf lib",
"lint": "esw src webpack.config --color",
"lint:fix": "npm run lint -- --fix",
"lint:watch": "npm run lint -- --watch",
"prepublish": "npm run lint && npm run test && npm run build",
"test": "ava",
"test:watch": "ava -w",
"test:cover": "nyc ava",
"test:report": "nyc report --reporter html",
"start": "webpack-dev-server"
},
"files": [
"lib",
"dist",
"assets"
],
"directories": { "directories": {
"example": "example" "example": "example"
}, },
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/smeijer/L.GeoSearch.git"
},
"author": "Stephan Meijer",
"license": "MIT",
"bugs": {
"url": "https://github.com/smeijer/L.GeoSearch/issues"
},
"homepage": "https://github.com/smeijer/L.GeoSearch#readme",
"keywords": [ "keywords": [
"geolocation", "geolocation",
"geocoding", "geocoding",
@@ -26,5 +38,58 @@
"leaflet", "leaflet",
"geo", "geo",
"map" "map"
] ],
"repository": {
"type": "git",
"url": "git+https://github.com/smeijer/L.GeoSearch.git"
},
"author": "Stephan Meijer <stephan@meijer.ws>",
"bugs": {
"url": "https://github.com/smeijer/L.GeoSearch/issues"
},
"homepage": "https://github.com/smeijer/L.GeoSearch#readme",
"license": "MIT",
"devDependencies": {
"ava": "^0.17.0",
"babel-cli": "^6.18.0",
"babel-eslint": "^7.1.1",
"babel-loader": "^6.2.10",
"babel-plugin-transform-react-jsx": "^6.8.0",
"babel-polyfill": "^6.20.0",
"babel-preset-es2015": "^6.18.0",
"babel-preset-power-assert": "^1.0.0",
"babel-preset-stage-0": "^6.16.0",
"browser-env": "^2.0.16",
"cross-env": "^3.1.3",
"dotenv": "^2.0.0",
"eslint": "^3.12.2",
"eslint-config-airbnb": "^13.0.0",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jsx-a11y": "^2.2.3",
"eslint-plugin-react": "^6.8.0",
"eslint-watch": "^2.1.14",
"leaflet": "^1.0.2",
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.0",
"node-fetch": "^1.6.3",
"power-assert": "^1.4.2",
"preact": "^7.1.0",
"raw-loader": "^0.5.1",
"rimraf": "^2.5.4",
"testdouble": "^1.10.0",
"webpack": "^1.14.0",
"webpack-dev-server": "^1.16.2"
},
"dependencies": {},
"ava": {
"files": [
"./src/**/__tests__/**.spec.js"
],
"require": [
"dotenv/config",
"babel-core/register",
"./test/browserEnv"
],
"babel": "inherit"
}
} }

View File

@@ -0,0 +1,97 @@
import test from 'ava';
import td from 'testdouble';
import LeafletControl from '../leafletControl';
import id from '../../test/randomId';
function MapInstance(options) {
const div = document.createElement('div');
div.id = id();
document.body.appendChild(div);
const map = new L.map(div, options); // eslint-disable-line new-cap
this.div = div;
this.map = map;
}
test('Can init leaflet', (t) => {
const { div, map } = new MapInstance();
t.truthy(div._leaflet_id);
t.truthy(map._leaflet_id);
t.not(div._leaflet_id, map._leaflet_id);
t.true(map._initHooksCalled);
});
test('Can add geosearch control to leaflet', (t) => {
const { div, map } = new MapInstance();
const provider = { search: td.function() };
const control = new LeafletControl({
provider,
}).addTo(map);
const element = document.querySelector(`#${div.id}`);
t.truthy(element._leaflet_events);
t.is(control._map, map);
});
test('It toggles the active class when the search button is clicked', (t) => {
const { map } = new MapInstance();
const provider = { search: td.function() };
const control = new LeafletControl({
provider,
}).addTo(map);
const { button } = control.elements;
const { container } = control.searchElement.elements;
button.click(new Event('click'));
t.regex(container.className, /active/);
button.click(new Event('click'));
t.notRegex(container.className, /active/);
});
test('Shows result on submit', async () => {
const { map } = new MapInstance();
const query = 'some city';
const result = [{ x: 0, y: 50 }];
const provider = { search: td.function() };
td.when(provider.search(query))
.thenReturn(Promise.resolve(result));
const control = new LeafletControl({
provider,
}).addTo(map);
control.showResult = td.function();
await control.onSubmit('some city');
td.verify(control.showResult(result[0]));
});
test('Change view on result', () => {
const { map } = new MapInstance({
center: [180, 180],
zoom: 18,
animateZoom: false,
});
map.setView = td.function();
const control = new LeafletControl({
}).addTo(map);
control.showResult({ x: 50, y: 0 });
td.verify(map.setView(
td.matchers.isA(L.LatLng),
td.matchers.isA(Number),
td.matchers.anything(),
));
});

View File

@@ -0,0 +1,29 @@
import test from 'ava';
import td from 'testdouble';
import SearchElement from '../searchElement';
test('Can localize texts', (t) => {
const searchLabel = 'Lookup address';
const control = new SearchElement({
searchLabel,
});
const { input } = control.elements;
t.is(input.getAttribute('placeholder'), searchLabel);
});
test('It will search when enter key is pressed', () => {
const handleSubmit = td.function();
const control = new SearchElement({
handleSubmit,
});
const { input } = control.elements;
input.value = 'Nederland';
input.dispatchEvent(new KeyboardEvent('keypress', {
keyCode: 13,
}));
td.verify(handleSubmit({ query: 'Nederland' }));
});

2
src/constants.js Normal file
View File

@@ -0,0 +1,2 @@
export const ENTER_KEY = 13;
export const ESCAPE_KEY = 27;

39
src/domUtils.js Normal file
View File

@@ -0,0 +1,39 @@
/* eslint-disable import/prefer-default-export */
export const createElement = (element, classNames = '', parent = null) => {
const el = document.createElement(element);
el.className = classNames;
if (parent) {
parent.appendChild(el);
}
return el;
};
export const createScriptElement = (url, cb) => {
const script = createElement('script', null, document.body);
script.setAttribute('type', 'text/javascript');
return new Promise((resolve) => {
window[cb] = (json) => {
script.remove();
delete window[cb];
resolve(json);
};
script.setAttribute('src', url);
});
};
export const addClassName = (element, className) => {
if (element && !element.classList.contains(className)) {
element.classList.add(className);
}
};
export const removeClassName = (element, className) => {
if (element && element.classList.contains(className)) {
element.classList.remove(className);
}
};

8
src/index.js Normal file
View File

@@ -0,0 +1,8 @@
export { default as GeoSearchControl } from './leafletControl';
export { default as SearchElement } from './searchElement';
export { default as BingProvider } from './providers/bingProvider';
export { default as EsriProvider } from './providers/esriProvider';
export { default as GoogleProvider } from './providers/googleProvider';
export { default as OpenStreetMapProvider } from './providers/openStreetMapProvider';
export { default as Provider } from './providers/provider';

View File

@@ -1,289 +0,0 @@
/*
* L.Control.GeoSearch - search for an address and zoom to its location
* https://github.com/smeijer/L.GeoSearch
*/
L.GeoSearch = {};
L.GeoSearch.Provider = {};
L.GeoSearch.Result = function (x, y, label, bounds, details) {
this.X = x;
this.Y = y;
this.Label = label;
this.bounds = bounds;
if (details)
this.details = details;
};
L.Control.GeoSearch = L.Control.extend({
options: {
position: 'topleft',
showMarker: true,
showPopup: false,
customIcon: false,
retainZoomLevel: false,
draggable: false
},
_config: {
country: '',
searchLabel: 'Enter address',
notFoundMessage: 'Sorry, that address could not be found.',
messageHideDelay: 3000,
zoomLevel: 18
},
initialize: function (options) {
L.Util.extend(this.options, options);
L.Util.extend(this._config, options);
},
resetLink: function(extraClass) {
var link = this._container.querySelector('a');
link.className = 'leaflet-bar-part leaflet-bar-part-single' + ' ' + extraClass;
},
onAdd: function (map) {
this._container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-geosearch');
// create the link - this will contain one of the icons
var link = L.DomUtil.create('a', '', this._container);
link.href = '#';
link.title = this._config.searchLabel;
// set the link's icon to magnifying glass
this.resetLink('glass');
// create the form that will contain the input
var form = L.DomUtil.create('form', 'displayNone', this._container);
// create the input, and set its placeholder text
var searchbox = L.DomUtil.create('input', null, form);
searchbox.type = 'text';
searchbox.placeholder = this._config.searchLabel;
this._searchbox = searchbox;
var msgbox = L.DomUtil.create('div', 'leaflet-bar message displayNone', this._container);
this._msgbox = msgbox;
L.DomEvent
.on(link, 'click', L.DomEvent.stopPropagation)
.on(link, 'click', L.DomEvent.preventDefault)
.on(link, 'click', function() {
if (L.DomUtil.hasClass(form, 'displayNone')) {
L.DomUtil.removeClass(form, 'displayNone'); // unhide form
searchbox.focus();
} else {
L.DomUtil.addClass(form, 'displayNone'); // hide form
}
})
.on(link, 'dblclick', L.DomEvent.stopPropagation);
L.DomEvent
.addListener(this._searchbox, 'keypress', this._onKeyPress, this)
.addListener(this._searchbox, 'keyup', this._onKeyUp, this)
.addListener(this._searchbox, 'input', this._onInput, this);
L.DomEvent.disableClickPropagation(this._container);
return this._container;
},
geosearch: function (qry) {
var that = this;
try {
var provider = this._config.provider;
if(typeof provider.GetLocations == 'function') {
provider.GetLocations(qry, function(results) {
that._processResults(results, qry);
});
}
else {
var url = provider.GetServiceUrl(qry);
this.sendRequest(provider, url, qry);
}
}
catch (error) {
this._printError(error);
}
},
cancelSearch: function() {
var form = this._container.querySelector('form');
L.DomUtil.addClass(form, 'displayNone');
this._searchbox.value = '';
this.resetLink('glass');
L.DomUtil.addClass(this._msgbox, 'displayNone');
this._map._container.focus();
},
startSearch: function() {
// show spinner icon
this.resetLink('spinner');
this.geosearch(this._searchbox.value);
},
sendRequest: function (provider, url, qry) {
var that = this;
window.parseLocation = function (response) {
var results = provider.ParseJSON(response);
that._processResults(results, qry);
document.body.removeChild(document.getElementById('getJsonP'));
delete window.parseLocation;
};
function getJsonP (url) {
url = url + '&callback=parseLocation';
var script = document.createElement('script');
script.id = 'getJsonP';
script.src = url;
script.async = true;
document.body.appendChild(script);
}
if (XMLHttpRequest) {
var xhr = new XMLHttpRequest();
if ('withCredentials' in xhr) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
var response = JSON.parse(xhr.responseText),
results = provider.ParseJSON(response);
that._processResults(results, qry);
} else if (xhr.status == 0 || xhr.status == 400) {
getJsonP(url);
} else {
that._printError(xhr.responseText);
}
}
};
xhr.open('GET', url, true);
xhr.send();
} else if (XDomainRequest) {
var xdr = new XDomainRequest();
xdr.onerror = function (err) {
that._printError(err);
};
xdr.onload = function () {
var response = JSON.parse(xdr.responseText),
results = provider.ParseJSON(response);
that._processResults(results, qry);
};
xdr.open('GET', url);
xdr.send();
} else {
getJsonP(url);
}
}
},
_processResults: function(results, qry) {
if (results.length > 0) {
this._map.fireEvent('geosearch_foundlocations', {Locations: results});
this._showLocation(results[0], qry);
this.cancelSearch();
} else {
this._printError(this._config.notFoundMessage);
}
},
_showLocation: function (location, qry) {
if (this.options.showMarker == true) {
if (typeof this._positionMarker === 'undefined') {
this._positionMarker = L.marker(
[location.Y, location.X],
{draggable: this.options.draggable}
).addTo(this._map);
if( this.options.customIcon ) {
this._positionMarker.setIcon(this.options.customIcon);
}
if( this.options.showPopup ) {
this._positionMarker.bindPopup(qry).openPopup();
}
}
else {
this._positionMarker.setLatLng([location.Y, location.X]);
if( this.options.showPopup ) {
this._positionMarker.bindPopup(qry).openPopup();
}
}
}
if (!this.options.retainZoomLevel && location.bounds && location.bounds.isValid()) {
this._map.fitBounds(location.bounds);
}
else {
this._map.setView([location.Y, location.X], this._getZoomLevel(), false);
}
this._map.fireEvent('geosearch_showlocation', {
Location: location,
Marker : this._positionMarker
});
},
_isShowingError: false,
_printError: function(message) {
this._msgbox.innerHTML = message;
L.DomUtil.removeClass(this._msgbox, 'displayNone');
this._map.fireEvent('geosearch_error', {message: message});
// show alert icon
this.resetLink('alert');
this._isShowingError = true;
},
_onKeyUp: function (e) {
var esc = 27;
if (e.keyCode === esc) { // escape key detection is unreliable
this.cancelSearch();
}
},
_getZoomLevel: function() {
if (! this.options.retainZoomLevel) {
return this._config.zoomLevel;
}
return this._map._zoom;
},
_onInput: function() {
if (this._isShowingError) {
this.resetLink('glass');
L.DomUtil.addClass(this._msgbox, 'displayNone');
this._isShowingError = false;
}
},
_onKeyPress: function (e) {
var enterKey = 13;
if (e.keyCode === enterKey) {
e.preventDefault();
e.stopPropagation();
this.startSearch();
}
}
});

View File

@@ -1,40 +0,0 @@
/**
* L.Control.GeoSearch - search for an address and zoom to it's location
* L.GeoSearch.Provider.Bing uses bing geocoding service
* https://github.com/smeijer/L.GeoSearch
*/
L.GeoSearch.Provider.Bing = L.Class.extend({
options: {
},
initialize: function(options) {
options = L.Util.setOptions(this, options);
},
GetServiceUrl: function (qry) {
var parameters = L.Util.extend({
query: qry,
jsonp: 'parseLocation'
}, this.options);
return 'http://dev.virtualearth.net/REST/v1/Locations'
+ L.Util.getParamString(parameters);
},
ParseJSON: function (data) {
if (data.resourceSets.length == 0 || data.resourceSets[0].resources.length == 0)
return [];
var results = [];
for (var i = 0; i < data.resourceSets[0].resources.length; i++)
results.push(new L.GeoSearch.Result(
data.resourceSets[0].resources[i].point.coordinates[1],
data.resourceSets[0].resources[i].point.coordinates[0],
data.resourceSets[0].resources[i].address.formattedAddress
));
return results;
}
});

View File

@@ -1,41 +0,0 @@
/**
* L.Control.GeoSearch - search for an address and zoom to it's location
* L.GeoSearch.Provider.Esri uses arcgis geocoding service
* https://github.com/smeijer/L.GeoSearch
*/
L.GeoSearch.Provider.Esri = L.Class.extend({
options: {
},
initialize: function(options) {
options = L.Util.setOptions(this, options);
},
GetServiceUrl: function (qry) {
var parameters = L.Util.extend({
text: qry,
f: 'pjson'
}, this.options);
return (location.protocol === 'https:' ? 'https:' : 'http:')
+ '//geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find'
+ L.Util.getParamString(parameters);
},
ParseJSON: function (data) {
if (data.locations.length == 0)
return [];
var results = [];
for (var i = 0; i < data.locations.length; i++)
results.push(new L.GeoSearch.Result(
data.locations[i].feature.geometry.x,
data.locations[i].feature.geometry.y,
data.locations[i].name
));
return results;
}
});

View File

@@ -1,106 +0,0 @@
/**
* L.Control.GeoSearch - search for an address and zoom to it's location
* L.GeoSearch.Provider.Google uses google geocoding service
* https://github.com/smeijer/L.GeoSearch
*/
onLoadGoogleApiCallback = function() {
L.GeoSearch.Provider.Google.Geocoder = new google.maps.Geocoder();
var scriptNode = document.getElementById('load_google_api');
if (!!scriptNode) {
document.body.removeChild(scriptNode);
}
};
L.GeoSearch.Provider.Google = L.Class.extend({
_isReady: false,
_onReadyQueue: [],
options: {
},
initialize: function(options) {
options = L.Util.setOptions(this, options);
if (!window.google || !window.google.maps) {
this.loadMapsApi();
} else {
// if google is already loaded, make sure we initialize the Geocoder
onLoadGoogleApiCallback();
}
},
loadMapsApi: function () {
var self = this;
var url = "https://maps.googleapis.com/maps/api/js?v=3&callback=onLoadGoogleApiCallback&sensor=false";
var script = document.createElement('script');
script.id = 'load_google_api';
script.type = "text/javascript";
script.src = url;
document.body.appendChild(script);
var handle = setInterval(function() {
if (typeof google !== 'undefined' && L.GeoSearch.Provider.Google.Geocoder instanceof google.maps.Geocoder) {
clearInterval(handle);
self._onReady();
}
}, 25);
},
_onReady: function() {
this._isReady = true;
var data;
while (data = this._onReadyQueue.shift()) {
this.GetLocations(data.qry, data.callback);
}
},
GetLocations: function(qry, callback) {
if (!this._isReady) {
// store calls to this method, so the can be re-invoked once
// the google api is loaded
this._onReadyQueue.push({ qry: qry, callback: callback });
return;
}
var geocoder = L.GeoSearch.Provider.Google.Geocoder;
var parameters = L.Util.extend({
address: qry
}, this.options);
var results = geocoder.geocode(parameters, function(data){
data = {results: data};
var results = [],
northEastLatLng,
southWestLatLng,
bounds;
for (var i = 0; i < data.results.length; i++) {
if( data.results[i].geometry.bounds ) {
var northEastGoogle = data.results[i].geometry.bounds.getNorthEast(),
southWestGoogle = data.results[i].geometry.bounds.getSouthWest();
northEastLatLng = new L.LatLng( northEastGoogle.lat(), northEastGoogle.lng() );
southWestLatLng = new L.LatLng( southWestGoogle.lat(), southWestGoogle.lng() );
bounds = new L.LatLngBounds([ northEastLatLng, southWestLatLng ]);
}
else {
bounds = undefined;
}
results.push(new L.GeoSearch.Result(
data.results[i].geometry.location.lng(),
data.results[i].geometry.location.lat(),
data.results[i].formatted_address,
bounds
));
}
if(typeof callback == 'function')
callback(results);
});
},
});

View File

@@ -1,40 +0,0 @@
/**
* L.Control.GeoSearch - search for an address and zoom to it's location
* L.GeoSearch.Provider.Nokia uses Nokia geocoding service
* https://github.com/smeijer/L.GeoSearch
*/
L.GeoSearch.Provider.Nokia = L.Class.extend({
options: {
},
initialize: function(options) {
options = L.Util.setOptions(this, options);
},
GetServiceUrl: function (qry) {
var parameters = L.Util.extend({
searchtext: qry,
jsoncallback: 'parseLocation'
}, this.options);
return 'http://geo.nlp.nokia.com/search/6.2/geocode.json'
+ L.Util.getParamString(parameters);
},
ParseJSON: function (data) {
if (data.Response.View.length == 0 || data.Response.View[0].Result.length == 0)
return [];
var results = [];
for (var i = 0; i < data.Response.View[0].Result.length; i++)
results.push(new L.GeoSearch.Result(
data.Response.View[0].Result[i].Location.DisplayPosition.Longitude,
data.Response.View[0].Result[i].Location.DisplayPosition.Latitude,
data.Response.View[0].Result[i].Location.Address.Label
));
return results;
}
});

View File

@@ -1,50 +0,0 @@
/**
* L.Control.GeoSearch - search for an address and zoom to it's location
* L.GeoSearch.Provider.OpenStreetMap uses openstreetmap geocoding service
* https://github.com/smeijer/L.GeoSearch
*/
L.GeoSearch.Provider.OpenStreetMap = L.Class.extend({
options: {},
initialize: function(options) {
options = L.Util.setOptions(this, options);
},
GetServiceUrl: function (qry) {
var parameters = L.Util.extend({
q: qry,
format: 'json'
}, this.options);
return (location.protocol === 'https:' ? 'https:' : 'http:')
+ '//nominatim.openstreetmap.org/search'
+ L.Util.getParamString(parameters);
},
ParseJSON: function (data) {
var results = [];
for (var i = 0; i < data.length; i++) {
var boundingBox = data[i].boundingbox,
northEastLatLng = new L.LatLng( boundingBox[1], boundingBox[3] ),
southWestLatLng = new L.LatLng( boundingBox[0], boundingBox[2] );
if (data[i].address)
data[i].address.type = data[i].type;
results.push(new L.GeoSearch.Result(
data[i].lon,
data[i].lat,
data[i].display_name,
new L.LatLngBounds([
northEastLatLng,
southWestLatLng
]),
data[i].address
));
}
return results;
}
});

139
src/leafletControl.js Normal file
View File

@@ -0,0 +1,139 @@
import GeoSearchElement from './searchElement';
import { createElement, addClassName, removeClassName } from './domUtils';
const defaultOptions = {
position: 'topleft',
showMarker: true,
showPopup: false,
marker: {
icon: new L.Icon.Default(),
draggable: false,
},
maxMarkers: 1,
retainZoomLevel: false,
animateZoom: true,
country: '',
searchLabel: 'Enter address',
notFoundMessage: 'Sorry, that address could not be found.',
messageHideDelay: 3000,
zoomLevel: 18,
classNames: {
container: 'leaflet-bar leaflet-control leaflet-control-geosearch',
button: 'leaflet-bar-part leaflet-bar-part-single',
msgbox: 'leaflet-bar message',
form: '',
input: '',
},
};
export default L.Control.extend({
initialize(options) {
this.markers = new L.FeatureGroup();
this.options = {
...defaultOptions,
...options,
};
const { classNames, searchLabel } = this.options;
this.searchElement = new GeoSearchElement({
...this.options,
handleSubmit: query => this.onSubmit(query),
});
const { container } = this.searchElement.elements;
container.addEventListener('dblclick', (e) => { e.stopPropagation(); });
const button = createElement('a', classNames.button, container);
button.title = searchLabel;
button.href = '#';
button.addEventListener('click', (e) => { this.onClick(e); }, false);
this.elements = { button };
},
onAdd(map) {
const { showMarker } = this.options;
this.map = map;
if (showMarker) {
this.markers.addTo(map);
}
return this.searchElement.elements.container;
},
onClick(event) {
event.preventDefault();
const { container, input } = this.searchElement.elements;
if (container.classList.contains('active')) {
removeClassName(container, 'active');
}
else {
addClassName(container, 'active');
input.focus();
}
},
async onSubmit(query) {
const { provider } = this.options;
const results = await provider.search(query);
if (results && results.length > 0) {
this.showResult(results[0]);
}
},
showResult(result) {
const markers = Object.keys(this.markers._layers);
if (markers.length >= this.options.maxMarkers) {
this.markers.removeLayer(markers[0]);
}
const marker = this.addMarker(result);
this.centerMap(result);
this.map.fireEvent('geosearch/showlocation', {
location: result,
marker,
});
},
addMarker(result) {
const { marker: options, showPopup } = this.options;
const marker = new L.Marker([result.y, result.x], options);
marker.bindPopup(result.label);
this.markers.addLayer(marker);
if (showPopup) {
marker.openPopup();
}
return marker;
},
centerMap(result) {
const { retainZoomLevel, animateZoom } = this.options;
const resultBounds = new L.LatLngBounds(result.bounds);
const bounds = resultBounds.isValid() ? resultBounds : this.markers.getBounds();
if (!retainZoomLevel && resultBounds.isValid()) {
this.map.fitBounds(bounds, { animate: animateZoom });
}
else {
this.map.setView(bounds.getCenter(), this.getZoom(), { animate: animateZoom });
}
},
getZoom() {
const { retainZoomLevel, zoomLevel } = this.options;
return retainZoomLevel ? this.map.getZoom() : zoomLevel;
},
});

View File

@@ -0,0 +1,33 @@
import test from 'ava';
import Provider from '../bingProvider';
test.skip('Can fetch results with Bing Provider', async (t) => {
const provider = new Provider({
params: {
key: process.env.BING_API_KEY,
},
});
const results = await provider.search({ query: 'nederland' });
const result = results[0];
t.truthy(result.label);
t.true(result.x > 5 && result.x < 6);
t.true(result.y > 50 && result.y < 55);
t.true(result.bounds[0][0] > result.bounds[0][1]);
t.true(result.bounds[1][0] > result.bounds[1][1]);
t.true(result.bounds[0][0] < result.bounds[1][0]);
t.true(result.bounds[0][1] < result.bounds[1][1]);
});
test.skip('Can get localized results', async (t) => {
const provider = new Provider({
params: {
key: process.env.BING_API_KEY,
c: 'nl',
},
});
const results = await provider.search({ query: 'leeuwarden' });
t.is(results[0].label, 'Leeuwarden, Nederland');
});

View File

@@ -0,0 +1,17 @@
import test from 'ava';
import Provider from '../esriProvider';
test('Can fetch results with Esri Provider', async (t) => {
const provider = new Provider();
const results = await provider.search({ query: 'nederland' });
const result = results[0];
t.truthy(result.label);
t.true(result.x > 5 && result.x < 6);
t.true(result.y > 50 && result.y < 55);
t.true(result.bounds[0][0] > result.bounds[0][1]);
t.true(result.bounds[1][0] > result.bounds[1][1]);
t.true(result.bounds[0][0] < result.bounds[1][0]);
t.true(result.bounds[0][1] < result.bounds[1][1]);
});

View File

@@ -0,0 +1,41 @@
import test from 'ava';
import Provider from '../googleProvider';
test('Can fetch results with Google Provider', async (t) => {
const provider = new Provider();
const results = await provider.search({ query: 'netherlands' });
const result = results[0];
t.truthy(result.label);
t.true(result.x > 5 && result.x < 6);
t.true(result.y > 50 && result.y < 55);
t.true(result.bounds[0][0] > result.bounds[0][1]);
t.true(result.bounds[1][0] > result.bounds[1][1]);
t.true(result.bounds[0][0] < result.bounds[1][0]);
t.true(result.bounds[0][1] < result.bounds[1][1]);
});
test('Can get localized results', async (t) => {
const provider = new Provider({
params: {
language: 'nl',
},
});
const results = await provider.search({ query: 'leeuwarden' });
t.is(results[0].label, 'Leeuwarden, Nederland');
});
test('Can fetch results with API Key', async (t) => {
const provider = new Provider({
params: {
key: process.env.GOOGLE_API_KEY,
},
});
const results = await provider.search({ query: 'nederland' });
const result = results[0];
t.truthy(result.label);
});

View File

@@ -0,0 +1,28 @@
import test from 'ava';
import Provider from '../openStreetMapProvider';
test('Can fetch results with OpenStreetMap', async (t) => {
const provider = new Provider();
const results = await provider.search({ query: 'nederland' });
const result = results[0];
t.truthy(result.label);
t.true(result.x > 5 && result.x < 6);
t.true(result.y > 50 && result.y < 55);
t.true(result.bounds[0][0] > result.bounds[0][1]);
t.true(result.bounds[1][0] > result.bounds[1][1]);
t.true(result.bounds[0][0] < result.bounds[1][0]);
t.true(result.bounds[0][1] < result.bounds[1][1]);
});
test('Can get localized results', async (t) => {
const provider = new Provider({
params: {
'accept-language': 'nl',
},
});
const results = await provider.search({ query: 'nederland' });
t.is(results[0].label, 'Nederland, Koninkrijk der Nederlanden');
});

View File

@@ -0,0 +1,42 @@
import BaseProvider from './provider';
import { createScriptElement } from '../domUtils';
export default class Provider extends BaseProvider {
endpoint({ query, protocol, jsonp } = {}) {
const { params } = this.options;
const paramString = this.getParamString({
...params,
query,
jsonp,
});
return `${protocol}//dev.virtualearth.net/REST/v1/Locations?${paramString}`;
}
parse({ data }) {
if (data.resourceSets.length === 0) {
return [];
}
return (data.resourceSets[0].resources).map(r => ({
x: r.point.coordinates[1],
y: r.point.coordinates[0],
label: r.address.formattedAddress,
bounds: [
[r.bbox[0], r.bbox[1]], // s, w
[r.bbox[2], r.bbox[3]], // n, e
],
}));
}
async search({ query }) {
const protocol = location.protocol === 'https:' ? 'https:' : 'http:';
const jsonp = `BING_JSONP_CB_${Date.now()}`;
const url = this.endpoint({ query, protocol, jsonp });
const json = await createScriptElement(url, jsonp);
return this.parse({ data: json });
}
}

View File

@@ -0,0 +1,27 @@
import BaseProvider from './provider';
export default class Provider extends BaseProvider {
endpoint({ query, protocol } = {}) {
const { params } = this.options;
const paramString = this.getParamString({
...params,
f: 'json',
text: query,
});
return `${protocol}//geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find?${paramString}`;
}
parse({ data }) {
return data.locations.map(r => ({
x: r.feature.geometry.x,
y: r.feature.geometry.y,
label: r.name,
bounds: [
[r.extent.ymin, r.extent.xmin], // s, w
[r.extent.ymax, r.extent.xmax], // n, e
],
}));
}
}

View File

@@ -0,0 +1,28 @@
import BaseProvider from './provider';
export default class Provider extends BaseProvider {
endpoint({ query, protocol } = {}) {
const { params } = this.options;
const paramString = this.getParamString({
...params,
address: query,
});
// google requires a secure connection when using api keys
const proto = (params && params.key) ? 'https:' : protocol;
return `${proto}//maps.googleapis.com/maps/api/geocode/json?${paramString}`;
}
parse({ data }) {
return data.results.map(r => ({
x: r.geometry.location.lng,
y: r.geometry.location.lat,
label: r.formatted_address,
bounds: [
[r.geometry.viewport.southwest.lat, r.geometry.viewport.southwest.lng], // s, w
[r.geometry.viewport.northeast.lat, r.geometry.viewport.northeast.lng], // n, e
],
}));
}
}

5
src/providers/index.js Normal file
View File

@@ -0,0 +1,5 @@
export { default as BingProvider } from './bingProvider';
export { default as EsriProvider } from './esriProvider';
export { default as GoogleProvider } from './googleProvider';
export { default as OpenStreetMapProvider } from './openStreetMapProvider';
export { default as Provider } from './provider';

View File

@@ -0,0 +1,27 @@
import BaseProvider from './provider';
export default class Provider extends BaseProvider {
endpoint({ query, protocol } = {}) {
const { params } = this.options;
const paramString = this.getParamString({
...params,
format: 'json',
q: query,
});
return `${protocol}//nominatim.openstreetmap.org/search?${paramString}`;
}
parse({ data }) {
return data.map(r => ({
x: r.lon,
y: r.lat,
label: r.display_name,
bounds: [
[parseFloat(r.boundingbox[0]), parseFloat(r.boundingbox[2])], // s, w
[parseFloat(r.boundingbox[1]), parseFloat(r.boundingbox[3])], // n, e
],
}));
}
}

20
src/providers/provider.js Normal file
View File

@@ -0,0 +1,20 @@
export default class Provider {
constructor(options = {}) {
this.options = options;
}
getParamString(params) {
return Object.keys(params).map(key =>
`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`,
).join('&');
}
async search({ query }) {
const protocol = location.protocol === 'https:' ? 'https:' : 'http:';
const url = this.endpoint({ query, protocol });
const request = await fetch(url);
const json = await request.json();
return this.parse({ data: json });
}
}

71
src/searchElement.js Normal file
View File

@@ -0,0 +1,71 @@
import { createElement, addClassName, removeClassName } from './domUtils';
import { ESCAPE_KEY, ENTER_KEY } from './constants';
export default class SearchElement {
constructor({ handleSubmit = () => {}, searchLabel = 'search', classNames = {} }) {
const container = createElement('div', ['geosearch', classNames.container].join(' '));
const form = createElement('form', ['', classNames.form].join(' '), container);
const input = createElement('input', ['glass', classNames.input].join(' '), form);
const msgbox = createElement('div', ['', classNames.msgbox].join(' '), container);
input.type = 'text';
input.placeholder = searchLabel;
input.addEventListener('input', (e) => { this.onInput(e); }, false);
input.addEventListener('keyup', (e) => { this.onKeyUp(e); }, false);
input.addEventListener('keypress', (e) => { this.onKeyPress(e); }, false);
input.addEventListener('focus', (e) => { this.onFocus(e); }, false);
input.addEventListener('blur', (e) => { this.onBlur(e); }, false);
this.elements = { container, form, input, msgbox };
this.handleSubmit = handleSubmit;
}
onFocus() {
addClassName(this.elements.form, 'active');
}
onBlur() {
removeClassName(this.elements.form, 'active');
}
async onSubmit(event) {
event.preventDefault();
event.stopPropagation();
const { input, container } = this.elements;
addClassName(container, 'pending');
await this.handleSubmit({ query: input.value });
removeClassName(container, 'pending');
}
onInput() {
const { container } = this.elements;
if (this.hasError) {
removeClassName(container, 'error');
this.hasError = false;
}
}
onKeyUp(event) {
const { container, input } = this.elements;
if (event.keyCode === ESCAPE_KEY) {
removeClassName(container, 'pending');
removeClassName(container, 'active');
input.value = '';
document.body.focus();
document.body.blur();
}
}
onKeyPress(event) {
if (event.keyCode === ENTER_KEY) {
this.onSubmit(event);
}
}
}

11
test/browserEnv.js Normal file
View File

@@ -0,0 +1,11 @@
/* eslint-disable import/newline-after-import, no-undef */
const browserEnv = require('browser-env');
browserEnv();
require('babel-polyfill');
const fetch = require('node-fetch');
window.fetch = global.fetch = fetch;
const leaflet = require('leaflet');
window.L = global.L = leaflet;

2
test/index.js Normal file
View File

@@ -0,0 +1,2 @@
export { default as id } from './randomId';
export { default as round } from './round';

3
test/randomId.js Normal file
View File

@@ -0,0 +1,3 @@
/* eslint-disable no-bitwise, prefer-template */
export default () => ('0000' + (Math.random() * Math.pow(36, 4) << 0)
.toString(36)).slice(-4);

9
test/round.js Normal file
View File

@@ -0,0 +1,9 @@
const round = (num) => {
if (Array.isArray(num)) {
return num.map(round);
}
return Math.round(num * 10) / 10;
};
export default round;

36
wallaby.js Normal file
View File

@@ -0,0 +1,36 @@
/* eslint-disable no-unused-vars, global-require, import/no-extraneous-dependencies */
const fs = require('fs');
process.env.BABEL_ENV = 'test';
process.env.NODE_ENV = 'production';
module.exports = wallaby => ({
files: [
'src/**/*.js',
'test/**/*.js',
'!src/**/__tests__/**/*.spec.js',
],
tests: [
'src/**/__tests__/**/*.spec.js',
],
compilers: {
'**/*.js': wallaby.compilers.babel(),
},
env: {
type: 'node',
params: {
env: [
'NODE_ENV=production',
].join(';'),
},
},
debug: false,
testFramework: 'ava',
setup: () => {
require('dotenv').config({
path: `${wallaby.localProjectDir}/.env`,
});
require('./test/browserEnv');
},
});

49
webpack.config.babel.js Normal file
View File

@@ -0,0 +1,49 @@
import webpack from 'webpack';
import path from 'path';
const { NODE_ENV } = process.env;
const production = NODE_ENV === 'production';
const plugins = [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
}),
];
if (production) {
plugins.push(
new webpack.optimize.UglifyJsPlugin({
compressor: {
pure_getters: true,
unsafe: true,
unsafe_comps: true,
screw_ie8: true,
warnings: false,
},
}),
);
}
export default {
entry: [
'babel-polyfill',
path.join(__dirname, 'src/index.js'),
],
output: {
path: path.join(__dirname, 'dist'),
filename: `bundle${production ? '.min' : ''}.js`,
library: 'GeoSearch',
libraryTarget: 'umd',
},
module: {
loaders: [
{
test: /\.js$/,
loaders: ['babel-loader'],
exclude: /node_modules/,
},
],
},
plugins,
};

34
webpack.config.js Normal file
View File

@@ -0,0 +1,34 @@
const webpack = require('webpack');
const path = require('path');
module.exports = {
entry: [
'babel-polyfill',
path.resolve(__dirname, 'example/main.js'),
path.resolve(__dirname, 'src/index.js'),
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
library: 'GeoSearch',
libraryTarget: 'umd',
publicPath: 'dist',
},
devServer: {
// contentBase: './example',
inline: true,
},
module: {
loaders: [
{
test: /\.html|\.css$/,
loader: 'raw-loader',
},
{
test: /\.js$/,
loaders: ['babel-loader'],
exclude: /node_modules/,
},
],
},
};

4687
yarn.lock Normal file

File diff suppressed because it is too large Load Diff