diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac3297e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bower_components/ +nbproject/ + diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..a7f3db1 --- /dev/null +++ b/bower.json @@ -0,0 +1,16 @@ +{ + "name": "brouter-web", + "version": "0.1.0", + "main": "js/index.js", + "ignore": [ + "**/.*", + "bower_components" + ], + "dependencies": { + "normalize-css": "*", + "leaflet-gpx": "mpetazzoni/leaflet-gpx", + "leaflet-search": "*", + "leaflet-plugins": "*", + "leaflet-routing": "Turistforeningen/leaflet-routing#gh-pages" + } +} diff --git a/css/leaflet-routing.css b/css/leaflet-routing.css new file mode 100644 index 0000000..cb0b24f --- /dev/null +++ b/css/leaflet-routing.css @@ -0,0 +1,5 @@ +div.line-mouse-marker { + background-color: #ffffff; + border: 2px solid black; + border-radius: 10px; +} diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..2efa6da --- /dev/null +++ b/css/style.css @@ -0,0 +1,125 @@ +html, body, #map { + height: 100%; +} + +.info { + padding: 6px 8px; + font: 14px/16px "Helvetica Neue", Arial, Helvetica, sans-serif; + box-shadow: 0px 0px 0px 3px rgba(70,130,180,0.2), 0 1px 5px rgba(0,0,0,0.4); + background: rgba(255,255,255,0.9); + border-radius: 5px; +} +.leaflet-control.elevation .background { + box-shadow: 0px 0px 0px 3px rgba(70,130,180,0.2); +} +div.elevation { + margin-bottom: -2px !important; +} +/* +.info, div.elevation { + display:table-row; +} +*/ + +.hidden { + display: none; +} + +#message { + position: absolute; + left: 446px; /* 400 + 10 + 26 + 10 */ + top: 0px; + margin-top: 10px; + z-index: 1000; + box-shadow: 0 1px 5px rgba(0,0,0,0.4); +} + +#header { + width: 100%; + margin: auto; + text-align: center; +} +#header, .heading { + color: #333; +} +.title { + padding-top: 4px; +} +.title-name { + font-size: larger; + text-shadow: 1px 1px 2px rgba(0,0,0,0.4); +} +.version { + font-size: x-small; +} +.header-text { + font-size: small; + margin-top: 0.5em; + line-height: 1.4em; +} +.hint { + color: orangered; +} + +.heading { + font-weight: bold; +} +.heading, .content, .content > .label, .content > .value { + float: left; +} + +table { + border-collapse: separate; + border-spacing: 4px; + /* spacing between cells only */ + margin: -4px; +} + +td { + vertical-align: top; + padding: 0; +} +#stats td:nth-child(2) { + text-align: right; +} + +.heading, tr > td:first-child, .label { + /* 1/4 of net info control width (370), so that values start at 50% */ + width: 92.5px; +} + +.leaflet-container { + cursor: auto; +} + +/* left sidebar as additional control position */ + +.leaflet-left { + left: 400px !important; +} + +.leaflet-leftpane { + left: 5px; + top: 7px; + bottom: 7px; + + /* + height: 100%; + display:table; + */ + position: absolute; + z-index: 1000; + pointer-events: none; +} + +.leaflet-leftpane .leaflet-control { + margin: 3px 5px; + width: 370px; +} + +/* TODO hack to maximize last div, further table-row tests, check out flex box */ +.leaflet-leftpane .leaflet-control:last-child { + position: absolute; + top: 432px; + bottom: 0px; +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..e0aeb5f --- /dev/null +++ b/index.html @@ -0,0 +1,100 @@ + + + + + BRouter desktop web client + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/control/Control.js b/js/control/Control.js new file mode 100644 index 0000000..0f89042 --- /dev/null +++ b/js/control/Control.js @@ -0,0 +1,37 @@ +BR.Control = L.Control.extend({ + options: { + position: 'leftpane' + }, + + onAdd: function (map) { + var container = L.DomUtil.create('div', 'info'), + heading, + div; + + if (this.options.heading) { + heading = L.DomUtil.create('div', 'heading', container); + heading.innerHTML = this.options.heading; + this._content = L.DomUtil.create('div', 'content', container); + } else { + this._content = container; + } + + if (this.options.divId) { + div = L.DomUtil.get(this.options.divId); + L.DomUtil.removeClass(div, 'hidden'); + this._content.appendChild(div); + } + + var stop = L.DomEvent.stopPropagation; + L.DomEvent + .on(container, 'click', stop) + .on(container, 'mousedown', stop) + .on(container, 'dblclick', stop); + // disabled because links not working, remove? + //L.DomEvent.on(container, 'click', L.DomEvent.preventDefault); + + return container; + } +}); + + diff --git a/js/control/Download.js b/js/control/Download.js new file mode 100644 index 0000000..c583dc8 --- /dev/null +++ b/js/control/Download.js @@ -0,0 +1,20 @@ +BR.Download = BR.Control.extend({ + options: { + heading: 'Download' + }, + + onAdd: function (map) { + var container = BR.Control.prototype.onAdd.call(this, map); + return container; + }, + + update: function (urls) { + var html = '
 
'; + if (urls.gpx) { + html += 'GPX · '; + html += 'KML'; + } + html += '
' + this._content.innerHTML = html; + } +}); diff --git a/js/control/Profile.js b/js/control/Profile.js new file mode 100644 index 0000000..e87ffe1 --- /dev/null +++ b/js/control/Profile.js @@ -0,0 +1,11 @@ +BR.Profile = BR.Control.extend({ + options: { + heading: '' + }, + + onAdd: function (map) { + var container = BR.Control.prototype.onAdd.call(this, map); + container.innerHTML = " "; + return container; + } +}); diff --git a/js/control/RoutingOptions.js b/js/control/RoutingOptions.js new file mode 100644 index 0000000..7a85b5b --- /dev/null +++ b/js/control/RoutingOptions.js @@ -0,0 +1,28 @@ +BR.RoutingOptions = BR.Control.extend({ + options: { + heading: 'Options', + divId: 'route_options' + }, + + onAdd: function (map) { + L.DomUtil.get('profile').onchange = this._getChangeHandler(); + L.DomUtil.get('alternative').onchange = this._getChangeHandler(); + + return BR.Control.prototype.onAdd.call(this, map); + }, + + getOptions: function() { + return { + profile: L.DomUtil.get('profile').value, + alternative: L.DomUtil.get('alternative').value + }; + }, + + _getChangeHandler: function() { + return L.bind(function(evt) { + this.fire('update', {options: this.getOptions()}); + }, this); + } +}); + +BR.RoutingOptions.include(L.Mixin.Events); diff --git a/js/control/TrackStats.js b/js/control/TrackStats.js new file mode 100644 index 0000000..82a899a --- /dev/null +++ b/js/control/TrackStats.js @@ -0,0 +1,49 @@ +BR.TrackStats = BR.Control.extend({ + options: { + heading: 'Route' + }, + + onAdd: function (map) { + var container = BR.Control.prototype.onAdd.call(this, map); + this.update(); + return container; + }, + + update: function (polyline) { + var stats = this.calcStats(polyline), + html = ''; + + html += ''; + html += ''; + html += ''; + html += ''; + html += '
Length: ' + L.Util.formatNum(stats.distance/1000,1) + 'km
Ascent: ' + Math.round(stats.elevationGain) + 'm
Descent: ' + Math.round(stats.elevationLoss) + 'm
'; + + this._content.innerHTML = html; + }, + + calcStats: function(polyline) { + var stats = { + distance: 0, + elevationGain: 0, + elevationLoss: 0 + }; + + var latLngs = polyline ? polyline.getLatLngs() : []; + for (var i = 0, current, next, eleDiff; i < latLngs.length - 1; i++) { + current = latLngs[i]; + next = latLngs[i + 1]; + stats.distance += current.distanceTo(next); + + // from Leaflet.gpx plugin (writes to LatLng.meta.ele, LatLng now supports ele) + eleDiff = (next.ele || next.meta.ele) - (current.ele || current.meta.ele); + if (eleDiff > 0) { + stats.elevationGain += eleDiff; + } else { + stats.elevationLoss += Math.abs(eleDiff); + } + } + + return stats; + } +}); diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..03e4971 --- /dev/null +++ b/js/index.js @@ -0,0 +1,124 @@ +/* + BRouter web - web client for BRouter bike routing engine + + Licensed under the MIT license. +*/ + +(function() { + + function initMap() { + var odblAttribution = 'data © OpenStreetMap contributors ' + + '(ODbL)'; + + var landscape = L.tileLayer('http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: 'tiles © Thunderforest ' + + '(CC-BY-SA 2.0)' + odblAttribution + }); + + var osm = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: 'tiles © OpenStreetMap contributors' + }); + + var map = new L.Map('map', { + layers: [osm], + center: new L.LatLng(50.99, 9.86), + zoom: 6 + }); + map.attributionControl.addAttribution( + 'BRouter © Arndt Brenschede, ' + + 'routing + map data © OpenStreetMap contributors ' + + '(ODbL)'); + + L.control.layers({ + 'OpenStreetMap': osm, + 'Landscape (Thunderforest)': landscape + }, { + /* + 'Hiking (Waymarked Trails)': hiking + */ + }).addTo(map); + + map.addControl(new L.Control.Permalink({text: 'Permalink', position: 'bottomright'})); //, layers: layersControl + map.addControl(new BR.Search()); + + return map; + } + + function initApp(map) { + var router, + routing, + routesLayer, + routingOptions, + nogos, + stats, + elevation, + download, + profile, + leftPaneId = 'leftpane'; + + // left sidebar as additional control position + map._controlCorners[leftPaneId] = L.DomUtil.create('div', 'leaflet-' + leftPaneId, map._controlContainer); + + router = L.bRouter(); //brouterCgi dummyRouter + + function updateRoute(evt) { + router.setOptions(evt.options); + routing.routeAllSegments(onUpdate); + } + + routingOptions = new BR.RoutingOptions(); + routingOptions.on('update', updateRoute); + + nogos = new BR.NogoAreas(); + nogos.on('update', updateRoute); + + // initial option settings + router.setOptions(nogos.getOptions()); + router.setOptions(routingOptions.getOptions()); + + stats = new BR.TrackStats(); + download = new BR.Download(); + elevation = new BR.Elevation(); + profile = new BR.Profile(); + + routing = new BR.Routing({routing: { + router: L.bind(router.getRouteSegment, router) + }}); + routing.on('routing:routeWaypointEnd', onUpdate); + + function onUpdate() { + var track = routing.toPolyline(), + latLngs = routing.getWaypoints(), + urls = {}; + + elevation.update(track); + stats.update(track); + + if (latLngs.length > 1) { + urls.gpx = router.getUrl(latLngs, 'gpx'); + urls.kml = router.getUrl(latLngs, 'kml'); + } + + download.update(urls); + }; + + map.addControl(new BR.Control({ + heading: '', + divId: 'header' + })); + routingOptions.addTo(map); + stats.addTo(map); + download.addTo(map); + elevation.addTo(map); + profile.addTo(map); + + nogos.addTo(map); + routing.addTo(map); + } + + map = initMap(); + initApp(map); + +})(); diff --git a/js/plugin/Elevation.js b/js/plugin/Elevation.js new file mode 100644 index 0000000..2231da4 --- /dev/null +++ b/js/plugin/Elevation.js @@ -0,0 +1,35 @@ +BR.Elevation = L.Control.Elevation.extend({ + options: { + position: "leftpane", + width: 385, + margins: { + top: 20, + right: 20, + bottom: 30, + left: 50 + }, + theme: "steelblue-theme" //purple + }, + + clear: function() { + this._data = []; + this._dist = 0; + this._maxElevation = 0; + + // workaround for 'Error: Problem parsing d=""' in Webkit when empty data + // https://groups.google.com/d/msg/d3-js/7rFxpXKXFhI/HzIO_NPeDuMJ + //this._areapath.datum(this._data).attr("d", this._area); + this._areapath.attr("d", "M0 0"); + + this._x.domain([0,1]); + this._y.domain([0,1]); + this._updateAxis(); + }, + + update: function(track) { + this.clear(); + if (track && track.getLatLngs().length > 0) { + this.addData(track); + } + } +}); \ No newline at end of file diff --git a/js/plugin/NogoAreas.js b/js/plugin/NogoAreas.js new file mode 100644 index 0000000..66fad17 --- /dev/null +++ b/js/plugin/NogoAreas.js @@ -0,0 +1,66 @@ +L.drawLocal.draw.toolbar.buttons.circle = 'Draw no-go area (circle)'; +L.drawLocal.edit.toolbar.buttons.edit = 'Edit no-go areas'; +L.drawLocal.edit.toolbar.buttons.remove = 'Delete no-go areas'; + +BR.NogoAreas = L.Control.Draw.extend({ + initialize: function () { + this.drawnItems = new L.FeatureGroup(); + + L.Control.Draw.prototype.initialize.call(this, { + draw: { + position: 'topleft', + polyline: false, + polygon: false, + circle: true, + rectangle: false, + marker: false + }, + edit: { + featureGroup: this.drawnItems, + //edit: false, + edit: { + selectedPathOptions: { + //opacity: 0.8 + } + }, + remove: true + } + }); + }, + + onAdd: function (map) { + map.addLayer(this.drawnItems); + + map.on('draw:created', function (e) { + var layer = e.layer; + this.drawnItems.addLayer(layer); + this._fireUpdate(); + }, this); + + map.on('draw:editstart', function (e) { + this.drawnItems.eachLayer(function (layer) { + layer.on('edit', function(e) { + this._fireUpdate(); + }, this); + }, this); + }, this); + + map.on('draw:deleted', function (e) { + this._fireUpdate(); + }, this); + + return L.Control.Draw.prototype.onAdd.call(this, map); + }, + + getOptions: function() { + return { + nogos: this.drawnItems.getLayers() + }; + }, + + _fireUpdate: function () { + this.fire('update', {options: this.getOptions()}); + } +}); + +BR.NogoAreas.include(L.Mixin.Events); \ No newline at end of file diff --git a/js/plugin/Routing.js b/js/plugin/Routing.js new file mode 100644 index 0000000..a4bf9c1 --- /dev/null +++ b/js/plugin/Routing.js @@ -0,0 +1,20 @@ +BR.Routing = L.Routing.extend({ + options: { + /* not implemented yet + icons: { + start: new L.Icon.Default({iconUrl: 'bower_components/leaflet-gpx/pin-icon-start.png'}), + end: new L.Icon.Default(), + normal: new L.Icon.Default() + },*/ + snapping: null + }, + + onAdd: function (map) { + var container = L.Routing.prototype.onAdd.call(this, map); + + // enable drawing mode + this.draw(true); + + return container; + } +}); diff --git a/js/plugin/Search.js b/js/plugin/Search.js new file mode 100644 index 0000000..daf4661 --- /dev/null +++ b/js/plugin/Search.js @@ -0,0 +1,17 @@ +BR.Search = L.Control.Search.extend({ + options: { + //url: 'http://nominatim.openstreetmap.org/search?format=json&q={s}', + url: 'http://open.mapquestapi.com/nominatim/v1/search.php?format=json&q={s}', + jsonpParam: 'json_callback', + propertyName: 'display_name', + propertyLoc: ['lat','lon'], + markerLocation: false, + autoType: false, + autoCollapse: true, + minLength: 2, + zoom: 12 + }, + + // patch: interferes with draw plugin (adds all layers twice to map?) + _onLayerAddRemove: function() {} +}); diff --git a/js/router/BRouter.js b/js/router/BRouter.js new file mode 100644 index 0000000..0cae7a7 --- /dev/null +++ b/js/router/BRouter.js @@ -0,0 +1,104 @@ +L.BRouter = L.Class.extend({ + statics: { + // http://localhost:17777/brouter?lonlats=1.1,1.2|2.1,2.2|3.1,3.2|4.1,4.2&nogos=-1.1,-1.2,1|-2.1,-2.2,2&profile=shortest&alternativeidx=1&format=kml + URL_TEMPLATE: 'http://localhost:17777/brouter?lonlats={lonlats}&nogos={nogos}&profile={profile}&alternativeidx={alternativeidx}&format={format}', + PRECISION: 6, + NUMBER_SEPARATOR: ',', + GROUP_SEPARATOR: '|' + }, + + options: { + format: 'gpx' + }, + + initialize: function (options) { + L.setOptions(this, options); + }, + + setOptions: function(options) { + L.setOptions(this, options); + }, + + getUrl: function(latLngs, format) { + var urlParams = { + lonlats: this._getLonLatsString(latLngs), + nogos: this._getNogosString(this.options.nogos), + profile: this.options.profile, + alternativeidx: this.options.alternative, + format: format || this.options.format + }; + var url = L.Util.template(L.BRouter.URL_TEMPLATE, urlParams); + return url; + }, + + getRoute: function(latLngs, cb) { + var url = this.getUrl(latLngs); + if (!url) { + return cb(new Error('Error getting route URL')); + } + + var gpxLayer = new L.GPX(url, { + async: true, + polyline_options: { + opacity: 0.6 + }, + marker_options: { + startIconUrl: null, + endIconUrl: null + } + }).on('loaded', function(e) { + // leaflet.spin + gpxLayer.fire('data:loaded'); + var gpx = e.target; + + return cb(null, gpx.getLayers()[0]); + })/* TODO no error handling in leaflet-gpx + .on('error', function(e){ + console.error('error'); + gpxLayer.fire('data:loaded'); + return cb(new Error('Routing failed')); + })*/; + }, + + getRouteSegment: function(l1, l2, cb) { + return this.getRoute([l1, l2], cb); + }, + + _getLonLatsString: function(latLngs) { + var s = ''; + for (var i = 0; i < latLngs.length; i++) { + s += this._formatLatLng(latLngs[i]); + if (i < (latLngs.length - 1)) { + s += L.BRouter.GROUP_SEPARATOR; + } + } + return s; + }, + + _getNogosString: function(nogos) { + var s = ''; + for (var i = 0, circle; i < nogos.length; i++) { + circle = nogos[i]; + s += this._formatLatLng(circle.getLatLng()); + s += L.BRouter.NUMBER_SEPARATOR; + s += Math.round(circle.getRadius()); + if (i < (nogos.length - 1)) { + s += L.BRouter.GROUP_SEPARATOR; + } + } + return s; + }, + + // formats L.LatLng object as lng,lat string + _formatLatLng: function(latLng) { + var s = ''; + s += L.Util.formatNum(latLng.lng, L.BRouter.PRECISION); + s += L.BRouter.NUMBER_SEPARATOR; + s += L.Util.formatNum(latLng.lat, L.BRouter.PRECISION); + return s; + } +}); + +L.bRouter = function (options) { + return new L.BRouter(options); +}; \ No newline at end of file diff --git a/js/router/brouterCgi.js b/js/router/brouterCgi.js new file mode 100644 index 0000000..a0a8023 --- /dev/null +++ b/js/router/brouterCgi.js @@ -0,0 +1,29 @@ +// BRouter online demo interface +// TODO remove or adopt to new structure (only supports two waypoints!) +var brouterCgi = (function() { + // http://h2096617.stratoserver.net/cgi-bin/brouter.sh?coords=13.404681_52.520185_13.340278_52.512356_trekking_0 + //var URL_TEMPLATE = '/cgi-bin/proxy.cgi?url=' + 'http://h2096617.stratoserver.net/cgi-bin/brouter.sh?coords={fromLng}_{fromLat}_{toLng}_{toLat}_{profile}_{alt}'; + var URL_TEMPLATE = '/proxy.php?url=' + 'cgi-bin/brouter.sh?coords={fromLng}_{fromLat}_{toLng}_{toLat}_{profile}_{alt}'; + var PRECISION = 6; + + function getUrl(polyline) { + var latLngs = polyline.getLatLngs(); + var urlParams = { + fromLat: L.Util.formatNum(latLngs[0].lat, PRECISION), + fromLng: L.Util.formatNum(latLngs[0].lng, PRECISION), + toLat: L.Util.formatNum(latLngs[1].lat, PRECISION), + toLng: L.Util.formatNum(latLngs[1].lng, PRECISION), + profile: 'trekking', + alt: '0' + }; + var url = L.Util.template(URL_TEMPLATE, urlParams); + //console.log(url); + //return 'test/test.gpx'; + return url; + } + + return { + getUrl: getUrl + } + +})(); \ No newline at end of file