Browse Source

Updated meshviewer to actual version v4 and discard all local changes

Michael Schwarz 8 years ago
parent
commit
a89f4c5b69
36 changed files with 993 additions and 549 deletions
  1. 39 0
      CHANGELOG.md
  2. 2 0
      Gemfile
  3. 13 0
      Gemfile.lock
  4. 1 1
      Gruntfile.js
  5. 87 6
      README.md
  6. 3 2
      app.js
  7. 2 1
      bower.json
  8. 34 0
      config.json
  9. 21 0
      config.json.example
  10. 4 4
      lib/about.js
  11. 5 2
      lib/container.js
  12. 197 117
      lib/forcegraph.js
  13. 44 32
      lib/gui.js
  14. 3 0
      lib/infobox/link.js
  15. 3 0
      lib/infobox/main.js
  16. 95 68
      lib/infobox/node.js
  17. 6 6
      lib/legend.js
  18. 7 9
      lib/linklist.js
  19. 4 4
      lib/main.js
  20. 90 101
      lib/map.js
  21. 22 20
      lib/map/clientlayer.js
  22. 19 11
      lib/map/labelslayer.js
  23. 16 9
      lib/meshstats.js
  24. 82 34
      lib/proportions.js
  25. 81 35
      lib/router.js
  26. 12 2
      lib/sidebar.js
  27. 1 4
      lib/simplenodelist.js
  28. 13 18
      lib/tabs.js
  29. 1 0
      package.json
  30. 1 1
      scss/_forcegraph.scss
  31. 1 0
      scss/_legend.scss
  32. 0 16
      scss/_map.scss
  33. 19 0
      scss/_shadow.scss
  34. 49 45
      scss/main.scss
  35. 15 0
      tasks/build.js
  36. 1 1
      tasks/development.js

+ 39 - 0
CHANGELOG.md

@@ -1,5 +1,44 @@
 # Change Log
 
+## v4
+
+- add a legend (map)
+- new graph theme
+- performance improvements in graph view
+- various UI changes
+- various map fixes
+- moved config from config.js to config.json
+- online/offline statistics
+- define layers for map in config
+- graph: zoom by keyboard (+ and - keys)
+- direct links to graph and map views
+
+### Bugfixes
+
+- map works with little or no nodes
+
+## v3
+
+### Implemented enhancements:
+
+- Make clients in map start at a random angle
+- On statistics page: show how many nodes supply geoinformation
+- Allow additional statistics (global and per node) configured in config.js
+- Improve node count information (total, online, clients, ...)
+- Show hardware model in link infobox
+- Introduce maxAge setting
+- Graph: show VPN links in grayscale
+
+### Removed features:
+
+- Don't show contact information in node lists
+
+### Fixed bugs:
+
+- Fixed off-by-one when drawing clients
+- Match labels order to node order in map
+- Statistics: count only nodes that are present
+
 ## v2
 
 ### General changes:

+ 2 - 0
Gemfile

@@ -0,0 +1,2 @@
+source 'https://rubygems.org'
+gem "sass"

+ 13 - 0
Gemfile.lock

@@ -0,0 +1,13 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    sass (3.4.16)
+
+PLATFORMS
+  ruby
+
+DEPENDENCIES
+  sass
+
+BUNDLED WITH
+   1.10.6

+ 1 - 1
Gruntfile.js

@@ -17,7 +17,7 @@ module.exports = function (grunt) {
 
   grunt.loadTasks("tasks")
 
-  grunt.registerTask("default", ["lint", "saveRevision", "copy", "sass", "requirejs"])
+  grunt.registerTask("default", ["bower-install-simple", "lint", "saveRevision", "copy", "sass", "requirejs"])
   grunt.registerTask("lint", ["eslint"])
   grunt.registerTask("dev", ["default", "connect:server", "watch"])
 }

+ 87 - 6
README.md

@@ -25,12 +25,30 @@ Meshviewer is a frontend for
 
 # Installing dependencies
 
+Install npm and Sass with your package-manager. On Debian-like systems run:
+
+    sudo apt-get install npm ruby-sass
+
+or if you have bundler you can install ruby-sass simply via `bundle install`
+
+Execute these commands on your server as a normal user to prepare the dependencies:
+
+    git clone https://github.com/tcatm/meshviewer.git
+    cd meshviewer
     npm install
-    bower install
+    npm install grunt-cli
+
+# Building
+
+Just run the following command from the meshviewer directory:
+
+    node_modules/.bin/grunt
+
+This will generate `build/` containing all required files.
 
 # Configure
 
-Copy `config.js.example` to `config.js` and change it to match your community.
+Copy `config.json.example` to `build/config.json` and change it to match your community.
 
 ## dataPath (string)
 
@@ -53,12 +71,75 @@ area. Values like 1.0 and 0.5 might be good choices.
 
 Setting this to `false` will hide contact information for nodes.
 
-# Building
+## maxAge (integer)
 
-Just run:
+Nodes being online for less than maxAge days are considered "new". Likewise,
+nodes being offline for less than than maxAge days are considered "lost".
 
-    grunt
+## mapLayers (List)
 
-This will generate `build/` containing all required files.
+A list of objects describing map layers. Each object has at least `name`
+property and optionally `url` and `config` properties. If no `url` is supplied
+`name` is assumed to name a
+[Leaflet-provider](http://leaflet-extras.github.io/leaflet-providers/preview/).
+
+## nodeInfos (array, optional)
+
+This option allows to show client statistics depending on following case-sensitive parameters:
+
+- `name` caption of statistics segment in infobox
+- `href` absolute or relative URL to statistics image
+- `thumbnail` absolute or relative URL to thumbnail image,
+  can be the same like `href`
+- `caption` is shown, if `thumbnail` is not present (no thumbnail in infobox)
+
+To insert current node-id in either `href`, `thumbnail` or `caption`
+you can use the case-sensitive template string `{NODE_ID}`.
+
+Examples for `nodeInfos`:
+
+    "nodeInfos": [
+      { "name": "Clientstatistik",
+        "href": "nodes/{NODE_ID}.png",
+        "thumbnail": "nodes/{NODE_ID}.png",
+        "caption": "Knoten {NODE_ID}"
+      },
+      { "name": "Uptime",
+        "href": "nodes_uptime/{NODE_ID}.png",
+        "thumbnail": "nodes_uptime/{NODE_ID}.png",
+        "caption": "Knoten {NODE_ID}"
+      }
+    ]
+
+In order to have statistics images available, you have to run the backend with parameter `--with-rrd` or generate them in other ways.
+
+## globalInfos (array, optional)
+
+This option allows to show global statistics on statistics page depending on following case-sensitive parameters:
+
+- `name` caption of statistics segment in infobox
+- `href` absolute or relative URL to statistics image
+- `thumbnail` absolute or relative URL to thumbnail image,
+  can be the same like `href`
+- `caption` is shown, if `thumbnail` is not present (no thumbnail in infobox)
+
+In contrast to `nodeInfos` there is no template substitution in  `href`, `thumbnail` or `caption`.
+
+Examples for `globalInfos`:
+
+    "globalInfos": [
+      { "name": "Wochenstatistik",
+        "href": "nodes/globalGraph.png",
+        "thumbnail": "nodes/globalGraph.png",
+        "caption": "Bild mit Wochenstatistik"
+      },
+      { "name": "Jahresstatistik",
+        "href": "nodes/globalGraph52.png",
+        "thumbnail": "nodes/globalGraph52.png",
+        "caption": "Bild mit Jahresstatistik"
+      }
+    ]
+
+In order to have global statistics available, you have to run the backend with parameter `--with-rrd` (this only creates globalGraph.png) or generate them in other ways.
 
 [CORS enabled]: http://enable-cors.org/server.html

+ 3 - 2
app.js

@@ -13,7 +13,8 @@ require.config({
     "numeral-intl": "../bower_components/numeraljs/min/languages.min",
     "virtual-dom": "../bower_components/virtual-dom/dist/virtual-dom",
     "rbush": "../bower_components/rbush/rbush",
-    "helper": "../helper"
+    "helper": "../helper",
+    "jshashes": "../bower_components/jshashes/hashes"
   },
   shim: {
     "leaflet.label": ["leaflet"],
@@ -31,5 +32,5 @@ require.config({
 })
 
 require(["main", "helper"], function (main) {
-  main()
+  getJSON("config.json").then(main)
 })

+ 2 - 1
bower.json

@@ -24,7 +24,8 @@
     "roboto-fontface": "~0.3.0",
     "virtual-dom": "~2.0.1",
     "leaflet-providers": "~1.0.27",
-    "rbush": "https://github.com/mourner/rbush.git#~1.3.5"
+    "rbush": "https://github.com/mourner/rbush.git#~1.3.5",
+    "jshashes": "~1.0.5"
   },
   "authors": [
     "Nils Schneider <nils@nilsschneider.net>"

+ 34 - 0
config.json

@@ -0,0 +1,34 @@
+{
+  "dataPath": "http://map-neu.paderborn.freifunk.net/data/",
+  "siteName": "Freifunk Paderborn",
+  "mapSigmaScale": 0.5,
+  "showContact": false,
+  "maxAge": 14,
+  "mapLayers": [
+    { "name": "OpenStreetmap",
+      "url": "http://{s}.tile.osm.org/{z}/{x}/{y}.png",
+      "config": {
+        "subdomains": "abc",
+        "type": "osm",
+        "attribution": "&copy; <a href=\"http://osm.org/copyright\">OpenStreetMap</a> contributors",
+        "maxZoom": 18
+      }
+    },
+    { "name": "MapQuest",
+      "url": "https://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.jpg",
+      "config": {
+        "subdomains": "1234",
+        "type": "osm",
+        "attribution": "Tiles &copy; <a href=\"https://www.mapquest.com/\" target=\"_blank\">MapQuest</a>, Data CC-BY-SA OpenStreetMap",
+        "maxZoom": 18
+      }
+    }
+  ],
+  "nodeInfos": [
+    { "name": "Clientstatistik",
+      "href": "nodestats/{NODE_ID}.png",
+      "thumbnail": "nodestats/{NODE_ID}.png",
+      "caption": "Knoten {NODE_ID}"
+    }
+  ]
+}

+ 21 - 0
config.json.example

@@ -0,0 +1,21 @@
+{
+  "dataPath": "https://map.luebeck.freifunk.net/data/",
+  "siteName": "Freifunk Lübeck",
+  "mapSigmaScale": 0.5,
+  "showContact": true,
+  "maxAge": 14,
+  "mapLayers": [
+    { "name": "MapQuest",
+      "url": "https://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.jpg",
+      "config": {
+        "subdomains": "1234",
+        "type": "osm",
+        "attribution": "Tiles &copy; <a href=\"https://www.mapquest.com/\" target=\"_blank\">MapQuest</a>, Data CC-BY-SA OpenStreetMap",
+        "maxZoom": 18
+      }
+    },
+    {
+      "name": "Stamen.TonerLite"
+    }
+  ]
+}

+ 4 - 4
lib/about.js

@@ -20,16 +20,16 @@ define(function () {
       s += "<p>This program is distributed in the hope that it will be useful, "
       s += "but WITHOUT ANY WARRANTY; without even the implied warranty of "
       s += "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the "
-      s += "GNU General Public License for more details.</p>"
+      s += "GNU Affero General Public License for more details.</p>"
 
       s += "<p>You should have received a copy of the GNU Affero General "
-      s += "Public License long with this program. If not, see "
+      s += "Public License along with this program. If not, see "
       s += "<a href=\"https://www.gnu.org/licenses/\">"
       s += "https://www.gnu.org/licenses/</a>.</p>"
 
       s += "<p>You may find the source code at "
-      s += "<a href=\"https://git.c3pb.de/freifunk-pb/map-website.git\">"
-      s += "https://git.c3pb.de/freifunk-pb/map-website.git</a>."
+      s += "<a href=\"http://draic.info/meshviewer\">"
+      s += "http://draic.info/meshviewer</a>."
 
       el.innerHTML = s
     }

+ 5 - 2
lib/container.js

@@ -1,8 +1,11 @@
 define([], function () {
-  return function () {
+  return function (tag) {
+    if (!tag)
+      tag = "div"
+
     var self = this
 
-    var container = document.createElement("div")
+    var container = document.createElement(tag)
 
     self.add = function (d) {
       d.render(container)

+ 197 - 117
lib/forcegraph.js

@@ -60,6 +60,9 @@ define(["d3"], function (d3) {
       d3.event.sourceEvent.stopPropagation()
       d3.event.sourceEvent.preventDefault()
       draggedNode.fixed |= 2
+
+      draggedNode.px = draggedNode.x
+      draggedNode.py = draggedNode.y
     }
 
     function dragmove() {
@@ -76,7 +79,7 @@ define(["d3"], function (d3) {
       if (draggedNode) {
         d3.event.sourceEvent.stopPropagation()
         d3.event.sourceEvent.preventDefault()
-        draggedNode.fixed &= 1
+        draggedNode.fixed &= ~2
         draggedNode = undefined
       }
     }
@@ -117,8 +120,6 @@ define(["d3"], function (d3) {
       }
     }
 
-    var translateP, scaleP
-
     function onPanZoom() {
       savedPanZoom = {translate: zoomBehavior.translate(),
                       scale: zoomBehavior.scale()}
@@ -142,7 +143,7 @@ define(["d3"], function (d3) {
     }
 
     function getSize() {
-      var sidebarWidth = sidebar.getWidth()
+      var sidebarWidth = sidebar()
       var width = el.offsetWidth - sidebarWidth
       var height = el.offsetHeight
 
@@ -150,7 +151,7 @@ define(["d3"], function (d3) {
     }
 
     function panzoomTo(a, b) {
-      var sidebarWidth = sidebar.getWidth()
+      var sidebarWidth = sidebar()
       var size = getSize()
 
       var targetWidth = Math.max(1, b[0] - a[0])
@@ -190,7 +191,6 @@ define(["d3"], function (d3) {
 
           if (l) {
             highlightedLinks = [l]
-            highlightedNodes = [l.source, l.target]
 
             if (!nopanzoom) {
               var x = d3.extent([l.source, l.target], function (d) { return d.x })
@@ -239,11 +239,11 @@ define(["d3"], function (d3) {
     }
 
     function visibleLinks(d) {
-      if (!d.o.vpn)
-        return (d.source.x > screenRect.left && d.source.x < screenRect.right &&
-                d.source.y > screenRect.top && d.source.y < screenRect.bottom) ||
-               (d.target.x > screenRect.left && d.target.x < screenRect.right &&
-                d.target.y > screenRect.top && d.target.y < screenRect.bottom)
+      return (d.source.x > screenRect.left && d.source.x < screenRect.right &&
+              d.source.y > screenRect.top && d.source.y < screenRect.bottom) ||
+             (d.target.x > screenRect.left && d.target.x < screenRect.right &&
+              d.target.y > screenRect.top && d.target.y < screenRect.bottom) ||
+             d.o.vpn
     }
 
     function visibleNodes(d) {
@@ -252,144 +252,185 @@ define(["d3"], function (d3) {
     }
 
     function redraw() {
+      var r = window.devicePixelRatio
       var translate = zoomBehavior.translate()
       var scale = zoomBehavior.scale()
       var links = intLinks.filter(visibleLinks)
 
-      var xExtent = d3.extent(intNodes, function (d) { return d.px })
-      var yExtent = d3.extent(intNodes, function (d) { return d.py })
-
-      if (translateP) {
-        ctx.save()
-        ctx.translate(translateP[0], translateP[1])
-        ctx.scale(scaleP, scaleP)
-        ctx.clearRect(xExtent[0] - margin, yExtent[0] - margin,
-                      xExtent[1] - xExtent[0] + 2 * margin,
-                      yExtent[1] - yExtent[0] + 2 * margin)
-        ctx.restore()
-      }
+      ctx.save()
+      ctx.setTransform(1, 0, 0, 1, 0, 0)
+      ctx.clearRect(0, 0, canvas.width, canvas.height)
+      ctx.restore()
 
       ctx.save()
       ctx.translate(translate[0], translate[1])
       ctx.scale(scale, scale)
 
-      if (!translateP)
-        ctx.clearRect(xExtent[0] - margin, yExtent[0] - margin,
-                      xExtent[1] - xExtent[0] + 2 * margin,
-                      yExtent[1] - yExtent[0] + 2 * margin)
+      var clientColor = "rgba(230, 50, 75, 1.0)"
+      var unknownColor = "#D10E2A"
+      var nodeColor = "#F2E3C6"
+      var highlightColor = "rgba(252, 227, 198, 0.15)"
+      var nodeRadius = 6
+
+      // -- draw links --
+      ctx.save()
+      links.forEach(function (d) {
+        var dx = d.target.x - d.source.x
+        var dy = d.target.y - d.source.y
+        var a = Math.sqrt(dx * dx + dy * dy)
+        dx /= a
+        dy /= a
+
+        ctx.beginPath()
+        ctx.moveTo(d.source.x + dx * nodeRadius, d.source.y + dy * nodeRadius)
+        ctx.lineTo(d.target.x - dx * nodeRadius, d.target.y - dy * nodeRadius)
+        ctx.strokeStyle = d.color
+        ctx.globalAlpha = d.o.vpn ? 0.1 : 0.8
+        ctx.lineWidth = d.o.vpn ? 1.5 : 2.5
+        ctx.stroke()
+      })
 
-      // Remeber last translate/scale state
-      translateP = translate
-      scaleP = scale
+      ctx.restore()
 
+      // -- draw unknown nodes --
       ctx.beginPath()
-      nodes.filter(visibleNodes).forEach(function (d) {
-        var clients = d.o.node.statistics.clients
-        if (d.clients === 0)
-          return
+      unknownNodes.filter(visibleNodes).forEach(function (d) {
+        ctx.moveTo(d.x + nodeRadius, d.y)
+        ctx.arc(d.x, d.y, nodeRadius, 0, 2 * Math.PI)
+      })
 
-        var distance = 16
-        var radius = 3
-        var a = 1.2
-        var startAngle = Math.PI
-        var angle = startAngle
+      ctx.strokeStyle = unknownColor
+      ctx.lineWidth = nodeRadius
 
-        for (var i = 0; i < clients; i++) {
-          if ((angle - startAngle) > 2 * Math.PI) {
-            angle = startAngle
-            distance += 2 * radius * a
-          }
+      ctx.stroke()
 
-          var x = d.x + distance * Math.cos(angle)
-          var y = d.y + distance * Math.sin(angle)
 
-          ctx.moveTo(x, y)
-          ctx.arc(x, y, radius, 0, 2 * Math.PI)
+      // -- draw nodes --
 
-          var n = Math.floor((Math.PI * distance) / (a * radius))
-          var angleDelta = 2 * Math.PI / n
-          angle += angleDelta
-        }
-      })
+      var node = document.createElement("canvas")
+      node.width = scale * nodeRadius * 8 * r
+      node.height = node.width
 
-      ctx.fillStyle = "#73A7CC"
-      ctx.fill()
+      var nctx = node.getContext("2d")
+      nctx.scale(scale * r, scale * r)
+      nctx.save()
 
-      if (highlightedLinks.length) {
-        ctx.save()
-        ctx.lineWidth = 10
-        ctx.strokeStyle = "#FFD486"
+      nctx.translate(-node.width / scale, -node.height / scale)
+      nctx.lineWidth = nodeRadius
 
-        highlightedLinks.forEach(function (d) {
-          ctx.beginPath()
-          ctx.moveTo(d.source.x, d.source.y)
-          ctx.lineTo(d.target.x, d.target.y)
-          ctx.stroke()
-        })
+      nctx.beginPath()
+      nctx.moveTo(nodeRadius, 0)
+      nctx.arc(0, 0, nodeRadius, 0, 2 * Math.PI)
 
-        ctx.restore()
-      }
+      nctx.strokeStyle = "rgba(255, 0, 0, 1)"
+      nctx.shadowOffsetX = node.width * 1.5 + 0
+      nctx.shadowOffsetY = node.height * 1.5 + 3
+      nctx.shadowBlur = 12
+      nctx.shadowColor = "rgba(0, 0, 0, 0.16)"
+      nctx.stroke()
+      nctx.shadowOffsetX = node.width * 1.5 + 0
+      nctx.shadowOffsetY = node.height * 1.5 + 3
+      nctx.shadowBlur = 12
+      nctx.shadowColor = "rgba(0, 0, 0, 0.23)"
+      nctx.stroke()
 
-      ctx.save()
+      nctx.restore()
+      nctx.translate(node.width / 2 / scale / r, node.height / 2 / scale / r)
 
-      links.forEach(function (d) {
-        ctx.beginPath()
-        ctx.moveTo(d.source.x, d.source.y)
-        ctx.lineTo(d.target.x, d.target.y)
-        ctx.strokeStyle = d.color
-        ctx.globalAlpha = d.o.vpn ? 0.1 : 1
-        ctx.lineWidth = d.o.vpn ? 1.5 : 2.5
-        ctx.stroke()
-      })
+      nctx.beginPath()
+      nctx.moveTo(nodeRadius, 0)
+      nctx.arc(0, 0, nodeRadius, 0, 2 * Math.PI)
 
-      ctx.restore()
+      nctx.strokeStyle = nodeColor
+      nctx.lineWidth = nodeRadius
+      nctx.stroke()
 
-      if (scale > 0.9)
-        intNodes.filter(visibleNodes).forEach(drawLabel, scale)
+      ctx.save()
+      ctx.scale(1 / scale / r, 1 / scale / r)
+      nodes.filter(visibleNodes).forEach(function (d) {
+        ctx.drawImage(node, scale * r * d.x - node.width / 2, scale * r * d.y - node.height / 2)
+      })
+      ctx.restore()
 
+      // -- draw clients --
+      ctx.save()
       ctx.beginPath()
+      nodes.filter(visibleNodes).forEach(function (d) {
+        var clients = d.o.node.statistics.clients
+        if (clients === 0)
+          return
 
-      unknownNodes.filter(visibleNodes).forEach(function (d) {
-        ctx.moveTo(d.x + 8, d.y)
-        ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI)
-      })
+        var startDistance = 16
+        var radius = 3
+        var a = 1.2
+        var startAngle = Math.PI
 
-      ctx.strokeStyle = "#d00000"
-      ctx.fillStyle = "#ffffff"
-      ctx.lineWidth = 2.5
+        for (var orbit = 0, i = 0; i < clients; orbit++) {
+          var distance = startDistance + orbit * 2 * radius * a
+          var n = Math.floor((Math.PI * distance) / (a * radius))
+          var delta = clients - i
 
-      ctx.fill()
-      ctx.stroke()
+          for (var j = 0; j < Math.min(delta, n); i++, j++) {
+            var angle = 2 * Math.PI / n * j
+            var x = d.x + distance * Math.cos(angle + startAngle)
+            var y = d.y + distance * Math.sin(angle + startAngle)
 
-      ctx.beginPath()
-      nodes.filter(visibleNodes).forEach(function (d) {
-        ctx.moveTo(d.x + 8, d.y)
-        ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI)
+            ctx.moveTo(x, y)
+            ctx.arc(x, y, radius, 0, 2 * Math.PI)
+          }
+        }
       })
 
-      ctx.strokeStyle = "#AEC7E8"
-      ctx.fillStyle = "#ffffff"
-
+      ctx.fillStyle = clientColor
       ctx.fill()
-      ctx.stroke()
+      ctx.restore()
 
+      // -- draw node highlights --
       if (highlightedNodes.length) {
         ctx.save()
-        ctx.strokeStyle = "#FFD486"
-        ctx.fillStyle = "orange"
-        ctx.lineWidth = 6
+        ctx.shadowColor = "rgba(255, 255, 255, 1.0)"
+        ctx.shadowBlur = 10 * nodeRadius
+        ctx.shadowOffsetX = 0
+        ctx.shadowOffsetY = 0
+        ctx.globalCompositeOperation = "lighten"
+        ctx.fillStyle = highlightColor
 
+        ctx.beginPath()
         highlightedNodes.forEach(function (d) {
-          ctx.beginPath()
-          ctx.moveTo(d.x + 8, d.y)
-          ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI)
-          ctx.fill()
-          ctx.stroke()
+          ctx.moveTo(d.x + 5 * nodeRadius, d.y)
+          ctx.arc(d.x, d.y, 5 * nodeRadius, 0, 2 * Math.PI)
         })
+        ctx.fill()
 
         ctx.restore()
       }
 
+      // -- draw link highlights --
+      if (highlightedLinks.length) {
+        ctx.save()
+        ctx.lineWidth = 2 * 5 * nodeRadius
+        ctx.shadowColor = "rgba(255, 255, 255, 1.0)"
+        ctx.shadowBlur = 10 * nodeRadius
+        ctx.shadowOffsetX = 0
+        ctx.shadowOffsetY = 0
+        ctx.globalCompositeOperation = "lighten"
+        ctx.strokeStyle = highlightColor
+        ctx.lineCap = "round"
+
+        ctx.beginPath()
+        highlightedLinks.forEach(function (d) {
+          ctx.moveTo(d.source.x, d.source.y)
+          ctx.lineTo(d.target.x, d.target.y)
+        })
+        ctx.stroke()
+
+        ctx.restore()
+      }
+
+      // -- draw labels --
+      if (scale > 0.9)
+        intNodes.filter(visibleNodes).forEach(drawLabel, scale)
+
       ctx.restore()
     }
 
@@ -403,7 +444,7 @@ define(["d3"], function (d3) {
       canvas.height = el.offsetHeight * r
       canvas.style.width = el.offsetWidth + "px"
       canvas.style.height = el.offsetHeight + "px"
-      ctx.resetTransform()
+      ctx.setTransform(1, 0, 0, 1, 0, 0)
       ctx.scale(r, r)
       requestAnimationFrame(redraw)
     }
@@ -461,6 +502,8 @@ define(["d3"], function (d3) {
       }
 
       var links = intLinks.filter(function (d) {
+        return !d.o.vpn
+      }).filter(function (d) {
         return distanceLink(e, d.source, d.target) < LINE_RADIUS
       })
 
@@ -470,19 +513,46 @@ define(["d3"], function (d3) {
       }
     }
 
+    function zoom(z, scale) {
+      var size = getSize()
+      var newSize = [size[0] / scale, size[1] / scale]
+
+      var sidebarWidth = sidebar()
+      var delta = [size[0] - newSize[0], size[1] - newSize[1]]
+      var translate = z.translate()
+      var translateNew = [sidebarWidth + (translate[0] - sidebarWidth - delta[0] / 2) * scale, (translate[1] - delta[1] / 2) * scale]
+
+      animatePanzoom(translateNew, z.scale() * scale)
+    }
+
+    function keyboardZoom(z) {
+      return function () {
+        var e = d3.event
+
+        if (e.altKey || e.ctrlKey || e.metaKey)
+          return
+
+        if (e.keyCode === 43)
+          zoom(z, 1.41)
+
+        if (e.keyCode === 45)
+          zoom(z, 1 / 1.41)
+      }
+    }
+
     el = document.createElement("div")
     el.classList.add("graph")
-    self.div = el
 
     zoomBehavior = d3.behavior.zoom()
                      .scaleExtent([1 / 3, 3])
                      .on("zoom", onPanZoom)
-                     .translate([sidebar.getWidth(), 0])
+                     .translate([sidebar(), 0])
 
     canvas = d3.select(el)
+               .attr("tabindex", 1)
+               .on("keypress", keyboardZoom(zoomBehavior))
                .call(zoomBehavior)
                .append("canvas")
-               .attr("pointer-events", "all")
                .on("click", onClick)
                .call(draggableNode)
                .node()
@@ -552,7 +622,11 @@ define(["d3"], function (d3) {
         e.o = d
         e.source = newNodesDict[d.source.id]
         e.target = newNodesDict[d.target.id]
-        e.color = linkScale(d.tq).hex()
+
+        if (d.vpn)
+          e.color = "rgba(255, 255, 255, " + (0.6 / d.tq) + ")"
+        else
+          e.color = linkScale(d.tq).hex()
 
         return e
       })
@@ -568,7 +642,7 @@ define(["d3"], function (d3) {
 
         var name = nodeName(d)
 
-        var offset = 8
+        var offset = 5
         var lineWidth = 3
         var buffer = document.createElement("canvas")
         var r = window.devicePixelRatio
@@ -581,12 +655,9 @@ define(["d3"], function (d3) {
         bctx.scale(scale, scale)
         bctx.textBaseline = "middle"
         bctx.textAlign = "center"
-        bctx.lineWidth = lineWidth
-        bctx.lineCap = "round"
-        bctx.strokeStyle = "rgba(255, 255, 255, 0.8)"
-        bctx.fillStyle = "rgba(0, 0, 0, 0.6)"
-        bctx.miterLimit = 2
-        bctx.strokeText(name, buffer.width / (2 * scale), buffer.height / (2 * scale))
+        bctx.fillStyle = "rgba(242, 227, 198, 1.0)"
+        bctx.shadowColor = "rgba(0, 0, 0, 1)"
+        bctx.shadowBlur = 5
         bctx.fillText(name, buffer.width / (2 * scale), buffer.height / (2 * scale))
 
         d.label = buffer
@@ -665,6 +736,15 @@ define(["d3"], function (d3) {
       force.stop()
       canvas.remove()
       force = null
+
+      if (el.parentNode)
+        el.parentNode.removeChild(el)
+    }
+
+    self.render = function (d) {
+      d.appendChild(el)
+      resizeCanvas()
+      updateHighlight()
     }
 
     return self

+ 44 - 32
lib/gui.js

@@ -1,9 +1,9 @@
 define([ "chroma-js", "map", "sidebar", "tabs", "container", "meshstats",
-         "linklist", "nodelist", "simplenodelist", "infobox/main",
-         "proportions", "forcegraph", "title", "about", "legend" ],
-function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Linklist,
+         "legend", "linklist", "nodelist", "simplenodelist", "infobox/main",
+         "proportions", "forcegraph", "title", "about" ],
+function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Legend, Linklist,
           Nodelist, SimpleNodelist, Infobox, Proportions, ForceGraph,
-          Title, About, Legend) {
+          Title, About) {
   return function (config, router) {
     var self = this
     var dataTargets = []
@@ -11,9 +11,12 @@ function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Linklist,
     var content
     var contentDiv
 
-    var linkScale = chroma.scale(chroma.interpolate.bezier(["green", "yellow", "red"])).domain([1, 5])
+    var linkScale = chroma.scale(chroma.interpolate.bezier(["#04C714", "#FF5500", "#F02311"])).domain([1, 5])
     var sidebar
 
+    var buttons = document.createElement("div")
+    buttons.classList.add("buttons")
+
     function dataTargetRemove(d) {
       dataTargets = dataTargets.filter( function (e) { return d !== e })
     }
@@ -25,22 +28,27 @@ function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Linklist,
       router.removeTarget(content)
       dataTargetRemove(content)
       content.destroy()
-      contentDiv.removeChild(content.div)
+
       content = null
     }
 
     function addContent(K) {
       removeContent()
 
-      content = new K(config, linkScale, sidebar, router)
-      contentDiv.appendChild(content.div)
+      content = new K(config, linkScale, sidebar.getWidth, router, buttons)
+      content.render(contentDiv)
 
       if (latestData)
         content.setData(latestData)
 
       dataTargets.push(content)
       router.addTarget(content)
-      router.reload()
+    }
+
+    function mkView(K) {
+      return function () {
+        addContent(K)
+      }
     }
 
     contentDiv = document.createElement("div")
@@ -49,58 +57,62 @@ function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Linklist,
 
     sidebar = new Sidebar(document.body)
 
+    contentDiv.appendChild(buttons)
+
     var buttonToggle = document.createElement("button")
-    buttonToggle.classList.add("contenttoggle")
-    buttonToggle.classList.add("next-graph")
+    buttonToggle.textContent = ""
     buttonToggle.onclick = function () {
-      if (content.constructor === Map) {
-        buttonToggle.classList.remove("next-graph")
-        buttonToggle.classList.add("next-map")
-        addContent(ForceGraph)
-      } else {
-        buttonToggle.classList.remove("next-map")
-        buttonToggle.classList.add("next-graph")
-        addContent(Map)
-      }
+      if (content.constructor === Map)
+        router.view("g")
+      else
+        router.view("m")
     }
-    contentDiv.appendChild(buttonToggle)
+
+    buttons.appendChild(buttonToggle)
 
     var title = new Title(config)
+
+    var header = new Container("header")
     var infobox = new Infobox(config, sidebar, router)
     var tabs = new Tabs()
     var overview = new Container()
-    var meshstats = new Meshstats()
+    var meshstats = new Meshstats(config)
     var legend = new Legend()
-    var newnodeslist = new SimpleNodelist(config, "new", "firstseen", router, "Neue Knoten")
-    var lostnodeslist = new SimpleNodelist(config, "lost", "lastseen", router, "Verschwundene Knoten")
+    var newnodeslist = new SimpleNodelist("new", "firstseen", router, "Neue Knoten")
+    var lostnodeslist = new SimpleNodelist("lost", "lastseen", router, "Verschwundene Knoten")
     var nodelist = new Nodelist(router)
-    //var linklist = new Linklist(linkScale, router)
-    var statistics = new Proportions()
+    var linklist = new Linklist(linkScale, router)
+    var statistics = new Proportions(config)
     var about = new About()
 
     dataTargets.push(meshstats)
     dataTargets.push(newnodeslist)
     dataTargets.push(lostnodeslist)
     dataTargets.push(nodelist)
-    //dataTargets.push(linklist)
+    dataTargets.push(linklist)
     dataTargets.push(statistics)
 
-    overview.add(meshstats)
-    overview.add(legend)
+    sidebar.add(header)
+    header.add(meshstats)
+    header.add(legend)
+
     overview.add(newnodeslist)
     overview.add(lostnodeslist)
 
     sidebar.add(tabs)
-    tabs.add("Übersicht", overview)
+    tabs.add("Aktuelles", overview)
     tabs.add("Knoten", nodelist)
-    //tabs.add("Verbindungen", linklist)
+    tabs.add("Verbindungen", linklist)
     tabs.add("Statistiken", statistics)
     tabs.add("Über", about)
 
     router.addTarget(title)
     router.addTarget(infobox)
 
-    addContent(Map)
+    router.addView("m", mkView(Map))
+    router.addView("g", mkView(ForceGraph))
+
+    router.view("m")
 
     self.setData = function (data) {
       latestData = data

+ 3 - 0
lib/infobox/link.js

@@ -20,6 +20,9 @@ define(function () {
     attributeEntry(attributes, "TQ", showTq(d))
     attributeEntry(attributes, "Entfernung", showDistance(d))
     attributeEntry(attributes, "VPN", d.vpn ? "ja" : "nein")
+    var hw1 = dictGet(d.source.node.nodeinfo, ["hardware", "model"])
+    var hw2 = dictGet(d.target.node.nodeinfo, ["hardware", "model"])
+    attributeEntry(attributes, "Hardware", (hw1 != null ? hw1 : "unbekannt") + " – " + (hw2 != null ? hw2 : "unbekannt"))
 
     el.appendChild(attributes)
   }

+ 3 - 0
lib/infobox/main.js

@@ -7,11 +7,14 @@ define(["infobox/link", "infobox/node"], function (Link, Node) {
       if (el && el.parentNode) {
         el.parentNode.removeChild(el)
         el = undefined
+        sidebar.reveal()
       }
     }
 
     function create() {
       destroy()
+      sidebar.ensureVisible()
+      sidebar.hide()
 
       el = document.createElement("div")
       sidebar.container.insertBefore(el, sidebar.container.firstChild)

+ 95 - 68
lib/infobox/node.js

@@ -36,6 +36,13 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"],
     }
   }
 
+  function showStatus(d) {
+    return function (el) {
+      el.classList.add(d.flags.online ? "online" : "offline")
+      el.textContent = d.flags.online ? "online" : "offline, " + d.lastseen.fromNow(true)
+    }
+  }
+
   function showFirmware(d) {
     var release = dictGet(d.nodeinfo, ["software", "firmware", "release"])
     var base = dictGet(d.nodeinfo, ["software", "firmware", "base"])
@@ -72,19 +79,6 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"],
       span.classList.add("clients")
       span.textContent = " ".repeat(d.statistics.clients)
       el.appendChild(span)
-
-      el.appendChild(document.createElement("br"))
-
-      var image = document.createElement("img")
-      image.setAttribute("src", "nodestats/" + d.nodeinfo.node_id + ".png")
-      image.setAttribute("width", "100%")
-
-      var link = document.createElement("a")
-      link.appendChild(image)
-      link.title = "nodegraph"
-      link.href = "nodestats/" + d.nodeinfo.node_id + ".png"
-      link.target = "_blank"
-      el.appendChild(link)
     }
   }
 
@@ -146,40 +140,75 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"],
     return au.enabled ? "aktiviert (" + au.branch + ")" : "deaktiviert"
   }
 
+  function showStatImg(o, nodeId) {
+    var content, caption
+
+    if (o.thumbnail) {
+      content = document.createElement("img")
+      content.src = o.thumbnail.replace("{NODE_ID}", nodeId)
+    }
+
+    if (o.caption) {
+      caption = o.caption.replace("{NODE_ID}", nodeId)
+
+      if (!content)
+        content = document.createTextNode(caption)
+    }
+
+    var p = document.createElement("p")
+
+    if (o.href) {
+      var link = document.createElement("a")
+      link.target = "_blank"
+      link.href = o.href.replace("{NODE_ID}", nodeId)
+      link.appendChild(content)
+
+      if (caption && o.thumbnail)
+        link.title = caption
+
+      p.appendChild(link)
+    } else
+      p.appendChild(content)
+
+    return p
+  }
+
   return function(config, el, router, d) {
     var h2 = document.createElement("h2")
     h2.textContent = d.nodeinfo.hostname
-    var span = document.createElement("span")
-    span.classList.add(d.flags.online ? "online" : "offline")
-    span.textContent = " (" + (d.flags.online ? "online" : "offline, " + d.lastseen.fromNow(true)) + ")"
-    h2.appendChild(span)
     el.appendChild(h2)
 
     var attributes = document.createElement("table")
     attributes.classList.add("attributes")
 
-    attributeEntry(attributes, "Gateway", d.flags.gateway ? "ja" : null)
+    attributeEntry(attributes, "Status", showStatus(d))
     attributeEntry(attributes, "Koordinaten", showGeoURI(d))
 
     if (config.showContact)
       attributeEntry(attributes, "Kontakt", dictGet(d.nodeinfo, ["owner", "contact"]))
 
     attributeEntry(attributes, "Hardware",  dictGet(d.nodeinfo, ["hardware", "model"]))
-    if (config.showPrimaryMac)
-      attributeEntry(attributes, "Primäre MAC", dictGet(d.nodeinfo, ["network", "mac"]))
-    if (config.showNodeId)
-      attributeEntry(attributes, "Node ID", dictGet(d.nodeinfo, ["node_id"]))
+    attributeEntry(attributes, "Primäre MAC", dictGet(d.nodeinfo, ["network", "mac"]))
+    attributeEntry(attributes, "Node ID", dictGet(d.nodeinfo, ["node_id"]))
     attributeEntry(attributes, "Firmware", showFirmware(d))
     attributeEntry(attributes, "Uptime", showUptime(d))
-    if (config.showFirstseen)
-      attributeEntry(attributes, "Teil des Netzes", showFirstseen(d))
-    if (config.showRam)
-      attributeEntry(attributes, "Arbeitsspeicher", showRAM(d))
+    attributeEntry(attributes, "Teil des Netzes", showFirstseen(d))
+    attributeEntry(attributes, "Arbeitsspeicher", showRAM(d))
     attributeEntry(attributes, "IP Adressen", showIPs(d))
     attributeEntry(attributes, "Autom. Updates", showAutoupdate(d))
     attributeEntry(attributes, "Clients", showClients(d))
+
     el.appendChild(attributes)
 
+
+    if (config.nodeInfos)
+      config.nodeInfos.forEach( function (nodeInfo) {
+        var h4 = document.createElement("h4")
+        h4.textContent = nodeInfo.name
+        el.appendChild(h4)
+        el.appendChild(showStatImg(nodeInfo, d.nodeinfo.node_id))
+      })
+
     if (d.neighbours.length > 0) {
       var h3 = document.createElement("h3")
       h3.textContent = "Nachbarknoten (" + d.neighbours.length + ")"
@@ -208,48 +237,46 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"],
       var tbody = document.createElement("tbody")
 
       d.neighbours.forEach( function (d) {
-        if (!d.link.vpn) {
-          var tr = document.createElement("tr")
-
-          var td1 = document.createElement("td")
-          var a1 = document.createElement("a")
-          a1.classList.add("hostname")
-          a1.textContent = d.node.nodeinfo.hostname
-          a1.href = "#"
-          a1.onclick = router.node(d.node)
-          td1.appendChild(a1)
-
-          if (d.link.vpn)
-            td1.appendChild(document.createTextNode(" (VPN)"))
-
-          if (has_location(d.node)) {
-            var span = document.createElement("span")
-            span.classList.add("icon")
-            span.classList.add("ion-location")
-            td1.appendChild(span)
-          }
-
-          tr.appendChild(td1)
-
-          var td2 = document.createElement("td")
-          var a2 = document.createElement("a")
-          a2.href = "#"
-          a2.textContent = showTq(d.link)
-          a2.onclick = router.link(d.link)
-          td2.appendChild(a2)
-          tr.appendChild(td2)
-
-          var td3 = document.createElement("td")
-          var a3 = document.createElement("a")
-          a3.href = "#"
-          a3.textContent = showDistance(d.link)
-          a3.onclick = router.link(d.link)
-          td3.appendChild(a3)
-          td3.setAttribute("data-sort", d.link.distance !== undefined ? -d.link.distance : 1)
-          tr.appendChild(td3)
-
-          tbody.appendChild(tr)
-	}
+        var tr = document.createElement("tr")
+
+        var td1 = document.createElement("td")
+        var a1 = document.createElement("a")
+        a1.classList.add("hostname")
+        a1.textContent = d.node.nodeinfo.hostname
+        a1.href = "#"
+        a1.onclick = router.node(d.node)
+        td1.appendChild(a1)
+
+        if (d.link.vpn)
+          td1.appendChild(document.createTextNode(" (VPN)"))
+
+        if (has_location(d.node)) {
+          var span = document.createElement("span")
+          span.classList.add("icon")
+          span.classList.add("ion-location")
+          td1.appendChild(span)
+        }
+
+        tr.appendChild(td1)
+
+        var td2 = document.createElement("td")
+        var a2 = document.createElement("a")
+        a2.href = "#"
+        a2.textContent = showTq(d.link)
+        a2.onclick = router.link(d.link)
+        td2.appendChild(a2)
+        tr.appendChild(td2)
+
+        var td3 = document.createElement("td")
+        var a3 = document.createElement("a")
+        a3.href = "#"
+        a3.textContent = showDistance(d.link)
+        a3.onclick = router.link(d.link)
+        td3.appendChild(a3)
+        td3.setAttribute("data-sort", d.link.distance !== undefined ? -d.link.distance : 1)
+        tr.appendChild(td3)
+
+        tbody.appendChild(tr)
       })
 
       table.appendChild(tbody)

+ 6 - 6
lib/legend.js

@@ -8,30 +8,30 @@ define(function () {
       el.appendChild(p)
 
       var spanNew = document.createElement("span")
-      var textNew = document.createTextNode(" Neuer Knoten.")
+      spanNew.setAttribute("class", "legend-new")
       var symbolNew = document.createElement("span")
       symbolNew.setAttribute("class", "symbol")
+      var textNew = document.createTextNode(" Neuer Knoten")
       spanNew.appendChild(symbolNew)
       spanNew.appendChild(textNew)
-      spanNew.setAttribute("class", "legend-new")
       p.appendChild(spanNew)
 
       var spanOnline = document.createElement("span")
-      var textOnline = document.createTextNode(" Knoten ist online.")
+      spanOnline.setAttribute("class", "legend-online")
       var symbolOnline = document.createElement("span")
       symbolOnline.setAttribute("class", "symbol")
+      var textOnline = document.createTextNode(" Knoten ist online")
       spanOnline.appendChild(symbolOnline)
       spanOnline.appendChild(textOnline)
-      spanOnline.setAttribute("class", "legend-online")
       p.appendChild(spanOnline)
 
       var spanOffline = document.createElement("span")
-      var textOffline = document.createTextNode(" Knoten ist offline.")
+      spanOffline.setAttribute("class", "legend-offline")
       var symbolOffline = document.createElement("span")
       symbolOffline.setAttribute("class", "symbol")
+      var textOffline = document.createTextNode(" Knoten ist offline")
       spanOffline.appendChild(symbolOffline)
       spanOffline.appendChild(textOffline)
-      spanOffline.setAttribute("class", "legend-offline")
       p.appendChild(spanOffline)
     }
 

+ 7 - 9
lib/linklist.js

@@ -25,18 +25,16 @@ define(["sorttable", "virtual-dom"], function (SortTable, V) {
     var table = new SortTable(headings, 2, renderRow)
 
     function renderRow(d) {
-      if (!d.vpn) {
-        var td1Content = [V.h("a", {href: "#", onclick: router.link(d)}, linkName(d))]
+      var td1Content = [V.h("a", {href: "#", onclick: router.link(d)}, linkName(d))]
 
-        if (d.vpn)
-          td1Content.push(" (VPN)")
+      if (d.vpn)
+        td1Content.push(" (VPN)")
 
-        var td1 = V.h("td", td1Content)
-        var td2 = V.h("td", {style: {color: linkScale(d.tq).hex()}}, showTq(d))
-        var td3 = V.h("td", showDistance(d))
+      var td1 = V.h("td", td1Content)
+      var td2 = V.h("td", {style: {color: linkScale(d.tq).hex()}}, showTq(d))
+      var td3 = V.h("td", showDistance(d))
 
-        return V.h("tr", [td1, td2, td3])
-      }
+      return V.h("tr", [td1, td2, td3])
     }
 
     this.render = function (d)  {

+ 4 - 4
lib/main.js

@@ -1,6 +1,6 @@
-define(["../config", "moment", "router", "leaflet", "gui", "numeral"],
-function (config, moment, Router, L, GUI, numeral) {
-  return function () {
+define(["moment", "router", "leaflet", "gui", "numeral"],
+function (moment, Router, L, GUI, numeral) {
+  return function (config) {
     function handleData(data) {
       var dataNodes = data[0]
       var dataGraph = data[1]
@@ -22,7 +22,7 @@ function (config, moment, Router, L, GUI, numeral) {
       })
 
       var now = moment()
-      var age = moment(now).subtract(14, "days")
+      var age = moment(now).subtract(config.maxAge, "days")
 
       var newnodes = limit("firstseen", age, sortByKey("firstseen", nodes).filter(online))
       var lostnodes = limit("lastseen", age, sortByKey("lastseen", nodes).filter(offline))

+ 90 - 101
lib/map.js

@@ -6,43 +6,27 @@ define(["map/clientlayer", "map/labelslayer",
                     zoomControl: false
                   }
 
-    var CoordsButton = L.Control.extend({
-      options: {
-        position: "bottomright"
-      },
-
-      active: false,
-      button: undefined,
-
-      initialize: function (f, options) {
-        L.Util.setOptions(this, options)
-        this.f = f
-      },
-
-      onAdd: function () {
-        var button = L.DomUtil.create("button", "show-coords")
-
-        L.DomEvent.disableClickPropagation(button)
-        L.DomEvent.addListener(button, "click", this.onClick, this)
-
-        this.button = button
+    var AddLayerButton = L.Control.extend({
+        options: {
+          position: "bottomright"
+        },
 
-        return button
-      },
+        initialize: function (f, options) {
+          L.Util.setOptions(this, options)
+          this.f = f
+        },
 
-      update: function() {
-        this.button.classList.toggle("active", this.active)
-      },
+        onAdd: function () {
+          var button = L.DomUtil.create("button", "add-layer")
+          button.textContent = ""
 
-      set: function(v) {
-        this.active = v
-        this.update()
-      },
+          L.DomEvent.disableClickPropagation(button)
+          L.DomEvent.addListener(button, "click", this.f, this)
 
-      onClick: function () {
-        this.f(!this.active)
-      }
+          this.button = button
 
+          return button
+        }
     })
 
     var LocateButton = L.Control.extend({
@@ -60,6 +44,7 @@ define(["map/clientlayer", "map/labelslayer",
 
         onAdd: function () {
           var button = L.DomUtil.create("button", "locate-user")
+          button.textContent = ""
 
           L.DomEvent.disableClickPropagation(button)
           L.DomEvent.addListener(button, "click", this.onClick, this)
@@ -132,10 +117,10 @@ define(["map/clientlayer", "map/labelslayer",
     var iconOnline  = { color: "#1566A9", fillColor: "#1566A9", radius: 6, fillOpacity: 0.5, opacity: 0.5, weight: 2, className: "stroke-first" }
     var iconOffline = { color: "#D43E2A", fillColor: "#D43E2A", radius: 3, fillOpacity: 0.5, opacity: 0.5, weight: 1, className: "stroke-first" }
     var iconLost    = { color: "#D43E2A", fillColor: "#D43E2A", radius: 6, fillOpacity: 0.8, opacity: 0.8, weight: 1, className: "stroke-first" }
-    var iconAlert   = iconLost
+    var iconAlert   = { color: "#D43E2A", fillColor: "#D43E2A", radius: 6, fillOpacity: 0.8, opacity: 0.8, weight: 2, className: "stroke-first node-alert" }
     var iconNew     = { color: "#1566A9", fillColor: "#93E929", radius: 6, fillOpacity: 1.0, opacity: 0.5, weight: 2 }
 
-    return function (config, linkScale, sidebar, router) {
+    return function (config, linkScale, sidebar, router, buttons) {
       var self = this
       var barycenter
       var groupOnline, groupOffline, groupNew, groupLost, groupLines
@@ -144,6 +129,7 @@ define(["map/clientlayer", "map/labelslayer",
       var map, userLocation
       var layerControl
       var customLayers = new Set()
+      var baseLayers = {}
 
       var locateUserButton = new LocateButton(function (d) {
         if (d)
@@ -152,12 +138,19 @@ define(["map/clientlayer", "map/labelslayer",
           disableTracking()
       })
 
-      var showCoordsButton = new CoordsButton(function (d) {
-        if (d)
-          enableCoords()
-        else
-          disableCoords()
-      })
+      var mybuttons = []
+
+      function addButton(button) {
+        var el = button.onAdd()
+        mybuttons.push(el)
+        buttons.appendChild(el)
+      }
+
+      function clearButtons() {
+        mybuttons.forEach( function (d) {
+          buttons.removeChild(d)
+        })
+      }
 
       function saveView() {
         savedView = {center: map.getCenter(),
@@ -178,18 +171,6 @@ define(["map/clientlayer", "map/labelslayer",
         locateUserButton.set(false)
       }
 
-      function enableCoords() {
-        map.getContainer().classList.add("pick-coordinates")
-        map.on("click", showCoordinates)
-        showCoordsButton.set(true)
-      }
-
-      function disableCoords() {
-        map.getContainer().classList.remove("pick-coordinates")
-        map.off("click", showCoordinates)
-        showCoordsButton.set(false)
-      }
-
       function locationFound(e) {
         if (!userLocation)
           userLocation = new LocationMarker(e.latlng).addTo(map)
@@ -198,11 +179,6 @@ define(["map/clientlayer", "map/labelslayer",
         userLocation.setAccuracy(e.accuracy)
       }
 
-      function showCoordinates(e) {
-        window.prompt("Koordinaten (Lat, Lng)", e.latlng.lat.toFixed(6) + ", " + e.latlng.lng.toFixed(6))
-        disableCoords()
-      }
-
       function locationError() {
         if (userLocation) {
           map.removeLayer(userLocation)
@@ -211,6 +187,9 @@ define(["map/clientlayer", "map/labelslayer",
       }
 
       function addLayer(layerName) {
+        if (layerName in baseLayers)
+          return
+
         if (customLayers.has(layerName))
           return
 
@@ -219,11 +198,6 @@ define(["map/clientlayer", "map/labelslayer",
           layerControl.addBaseLayer(layer, layerName)
           customLayers.add(layerName)
 
-          if (!layerControl.added) {
-            layerControl.addTo(map)
-            layerControl.added = true
-          }
-
           if (localStorageTest())
             localStorage.setItem("map/customLayers", JSON.stringify(Array.from(customLayers)))
         } catch (e) {
@@ -233,53 +207,51 @@ define(["map/clientlayer", "map/labelslayer",
 
       var el = document.createElement("div")
       el.classList.add("map")
-      self.div = el
 
       map = L.map(el, options)
 
-      var baseLayer = L.tileLayer("http://{s}.tile.osm.org/{z}/{x}/{y}.png", {
-                                    subdomains: "abc",
-                                    type: "osm",
-                                    attribution: "&copy; <a href=\"http://osm.org/copyright\">OpenStreetMap</a> contributors",
-                                    maxZoom: 18
-                                  }).addTo(map)
+      var layers = config.mapLayers.map( function (d) {
+        return {
+          "name": d.name,
+          "layer": "url" in d ? L.tileLayer(d.url, d.config) : L.tileLayer.provider(d.name)
+        }
+      })
+
+      layers[0].layer.addTo(map)
+
+      layers.forEach( function (d) {
+        baseLayers[d.name] = d.layer
+      })
 
       map.on("locationfound", locationFound)
       map.on("locationerror", locationError)
       map.on("dragend", saveView)
 
-      map.addControl(locateUserButton)
-      map.addControl(showCoordsButton)
+      addButton(locateUserButton)
 
-      layerControl = L.control.layers({"OpenStreetmap": baseLayer}, [], {position: "bottomright"})
+      addButton(new AddLayerButton(function () {
+        /*eslint no-alert:0*/
+        var layerName = prompt("Leaflet Provider:")
+        addLayer(layerName)
+      }))
 
-      // add additional layers by default
-      var layerAerial = L.tileLayer.provider("MapQuestOpen.OSM")
-      layerControl.addBaseLayer(layerAerial, "MapQuest")
-      customLayers.add("MapQuest")
-      if (!layerControl.added) {
-         layerControl.addTo(map)
-         layerControl.added = true
-      }
+      layerControl = L.control.layers(baseLayers, [], {position: "bottomright"})
+      layerControl.addTo(map)
 
       if (localStorageTest()) {
-        var layers = JSON.parse(localStorage.getItem("map/customLayers"))
+        var d = JSON.parse(localStorage.getItem("map/customLayers"))
 
-        if (layers)
-          layers.forEach(addLayer)
+        if (d)
+          d.forEach(addLayer)
       }
 
-      if (config.mapShowClients) {
-        var clientLayer = new ClientLayer({minZoom: 15})
-        clientLayer.addTo(map)
-        clientLayer.setZIndex(5)
-      }
+      var clientLayer = new ClientLayer({minZoom: 15})
+      clientLayer.addTo(map)
+      clientLayer.setZIndex(5)
 
-      if (config.mapShowLabels) {
-        var labelsLayer = new LabelsLayer()
-        labelsLayer.addTo(map)
-        labelsLayer.setZIndex(6)
-      }
+      var labelsLayer = new LabelsLayer()
+      labelsLayer.addTo(map)
+      labelsLayer.setZIndex(6)
 
       var nodeDict = {}
       var linkDict = {}
@@ -296,11 +268,12 @@ define(["map/clientlayer", "map/labelslayer",
       }
 
       function setView(bounds) {
-        map.fitBounds(bounds, {paddingTopLeft: [sidebar.getWidth(), 0]})
+        map.fitBounds(bounds, {paddingTopLeft: [sidebar(), 0]})
       }
 
       function resetZoom() {
-        setView(barycenter.getBounds())
+        if (barycenter)
+          setView(barycenter.getBounds())
       }
 
       function goto(m) {
@@ -344,12 +317,22 @@ define(["map/clientlayer", "map/labelslayer",
 
       function calcBarycenter(nodes) {
         nodes = nodes.map(function (d) { return d.nodeinfo.location })
+
+        if (nodes.length === 0)
+          return undefined
+
         var lats = nodes.map(function (d) { return d.latitude })
         var lngs = nodes.map(function (d) { return d.longitude })
 
         var barycenter = L.latLng(d3.median(lats), d3.median(lngs))
         var barycenterDev = [d3.deviation(lats), d3.deviation(lngs)]
 
+        if (barycenterDev[0] === undefined)
+          barycenterDev[0] = 0
+
+        if (barycenterDev[1] === undefined)
+          barycenterDev[1] = 0
+
         var barycenterCircle = L.latLng(barycenter.lat + barycenterDev[0],
                                         barycenter.lng + barycenterDev[1])
 
@@ -412,18 +395,16 @@ define(["map/clientlayer", "map/labelslayer",
           }, router))
 
         groupOffline = L.featureGroup(markersOffline).addTo(map)
-        groupLost = L.featureGroup(markersLost).addTo(map)
         groupOnline = L.featureGroup(markersOnline).addTo(map)
+        groupLost = L.featureGroup(markersLost).addTo(map)
         groupNew = L.featureGroup(markersNew).addTo(map)
 
         var rtreeOnlineAll = rbush(9)
 
         rtreeOnlineAll.load(data.nodes.all.filter(online).filter(has_location).map(mapRTree))
-        if (config.mapShowClients)
-          clientLayer.setData(rtreeOnlineAll)
 
-        if (config.mapShowLabels)
-          labelsLayer.setData({online: nodesOnline.filter(has_location),
+        clientLayer.setData(rtreeOnlineAll)
+        labelsLayer.setData({online: nodesOnline.filter(has_location),
                              offline: nodesOffline.filter(has_location),
                              new: data.nodes.new.filter(has_location),
                              lost: data.nodes.lost.filter(has_location)
@@ -451,10 +432,18 @@ define(["map/clientlayer", "map/labelslayer",
       }
 
       self.destroy = function () {
+        clearButtons()
         map.remove()
+
+        if (el.parentNode)
+          el.parentNode.removeChild(el)
+      }
+
+      self.render = function (d) {
+        d.appendChild(el)
+        map.invalidateSize()
       }
 
       return self
     }
 })
-

+ 22 - 20
lib/map/clientlayer.js

@@ -1,8 +1,16 @@
-define(["leaflet"],
-  function (L) {
+define(["leaflet", "jshashes"],
+  function (L, jsHashes) {
+    var MD5 = new jsHashes.MD5()
+
     return L.TileLayer.Canvas.extend({
       setData: function (d) {
         this.data = d
+
+        //pre-calculate start angles
+        this.data.all().forEach(function (d) {
+          var hash = MD5.hex(d.node.nodeinfo.node_id)
+          d.startAngle = (parseInt(hash.substr(0, 2), 16) / 255) * 2 * Math.PI
+        })
         this.redraw()
       },
       drawTile: function (canvas, tilePoint) {
@@ -33,7 +41,6 @@ define(["leaflet"],
         var radius = 3
         var a = 1.2
         var startDistance = 12
-        var startAngle = Math.PI
 
         ctx.beginPath()
         nodes.forEach(function (d) {
@@ -46,28 +53,23 @@ define(["leaflet"],
           p.x -= s.x
           p.y -= s.y
 
-          var distance = startDistance
-          var angle = startAngle
-
-          for (var i = 0; i < clients; i++) {
-            if ((angle - startAngle) > 2 * Math.PI) {
-              angle = startAngle
-              distance += 2 * radius * a
-            }
-
-            var x = p.x + distance * Math.cos(angle)
-            var y = p.y + distance * Math.sin(angle)
+          for (var orbit = 0, i = 0; i < clients; orbit++) {
+            var distance = startDistance + orbit * 2 * radius * a
+            var n = Math.floor((Math.PI * distance) / (a * radius))
+            var delta = clients - i
 
-            ctx.moveTo(x, y)
-            ctx.arc(x, y, radius, 0, 2 * Math.PI)
+            for (var j = 0; j < Math.min(delta, n); i++, j++) {
+              var angle = 2 * Math.PI / n * j
+              var x = p.x + distance * Math.cos(angle + d.startAngle)
+              var y = p.y + distance * Math.sin(angle + d.startAngle)
 
-            var n = Math.floor((Math.PI * distance) / (a * radius))
-            var angleDelta = 2 * Math.PI / n
-            angle += angleDelta
+              ctx.moveTo(x, y)
+              ctx.arc(x, y, radius, 0, 2 * Math.PI)
+            }
           }
         })
 
-        ctx.fillStyle = "rgba(153, 118, 16, 0.5)"
+        ctx.fillStyle = "rgba(220, 0, 103, 0.7)"
         ctx.fill()
       }
     })

+ 19 - 11
lib/map/labelslayer.js

@@ -48,21 +48,26 @@ define(["leaflet", "rbush"],
               -offset * Math.sin(loc[2] * 2 * Math.PI)]
     }
 
-    function labelRect(p, offset, anchor, label) {
+    function labelRect(p, offset, anchor, label, minZoom, maxZoom, z) {
+      var margin = 1 + 1.41 * (1 - (z - minZoom) / (maxZoom - minZoom))
+
+      var width = label.width * margin
+      var height = label.height * margin
+
       var dx = { left: 0,
-                 right: -label.width,
-                 center: -label.width /  2
+                 right: -width,
+                 center: -width /  2
                }
 
       var dy = { top: 0,
-                 ideographic: -label.height,
-                 middle: -label.height / 2
+                 ideographic: -height,
+                 middle: -height / 2
                }
 
       var x = p.x + offset[0] + dx[anchor[0]]
       var y = p.y + offset[1] + dy[anchor[1]]
 
-      return [x, y, x + label.width, y + label.height]
+      return [x, y, x + width, y + height]
     }
 
     var c = L.TileLayer.Canvas.extend({
@@ -125,7 +130,7 @@ define(["leaflet", "rbush"],
 
             for (z = maxZoom; z >= d.minZoom; z--) {
               var p = map.project(d.position, z)
-              var rect = labelRect(p, offset, loc, d)
+              var rect = labelRect(p, offset, loc, d, minZoom, maxZoom, z)
               var candidates = trees[z].search(rect)
 
               if (candidates.length > 0)
@@ -146,7 +151,7 @@ define(["leaflet", "rbush"],
 
             for (var z = maxZoom; z >= best.z; z--) {
               var p = map.project(d.position, z)
-              var rect = labelRect(p, d.offset, best.loc, d)
+              var rect = labelRect(p, d.offset, best.loc, d, minZoom, maxZoom, z)
               trees[z].insert(rect)
             }
 
@@ -155,9 +160,12 @@ define(["leaflet", "rbush"],
             return undefined
         }).filter(function (d) { return d !== undefined })
 
-        this.margin = 16 + labels.map(function (d) {
-          return d.width
-        }).sort().reverse()[0]
+        this.margin = 16
+
+        if (labels.length > 0)
+          this.margin += labels.map(function (d) {
+            return d.width
+          }).sort().reverse()[0]
 
         this.labels = rbush(9)
         this.labels.load(labels.map(mapRTree))

+ 16 - 9
lib/meshstats.js

@@ -1,27 +1,34 @@
 define(function () {
-  return function () {
+  return function (config) {
     var self = this
     var stats, timestamp
 
     self.setData = function (d) {
-      var totalNodes = sum(d.nodes.all.filter(online).map(one))
+      var totalNodes = sum(d.nodes.all.map(one))
+      var totalOnlineNodes = sum(d.nodes.all.filter(online).map(one))
+      var totalNewNodes = sum(d.nodes.new.map(one))
+      var totalLostNodes = sum(d.nodes.lost.map(one))
       var totalClients = sum(d.nodes.all.filter(online).map( function (d) {
         return d.statistics.clients
       }))
-      var totalGateways = sum(d.nodes.all.filter(online).filter( function (d) {
-        return d.flags.gateway
-      }).map(one))
 
-      stats.textContent = totalNodes + " Knoten (online), " +
-                          totalClients + " Clients, " +
-                          totalGateways + " Gateways"
+      var nodetext = [{ count: totalOnlineNodes, label: "online" },
+                      { count: totalNewNodes, label: "neu" },
+                      { count: totalLostNodes, label: "verschwunden" }
+                     ].filter( function (d) { return d.count > 0 } )
+                      .map( function (d) { return [d.count, d.label].join(" ") } )
+                      .join(", ")
+
+      stats.textContent = totalNodes + " Knoten " +
+                          "(" + nodetext + ") mit " +
+                          totalClients + " Client" + ( totalClients === 1 ? "" : "s" )
 
       timestamp.textContent = "Diese Daten sind von " + d.timestamp.format("LLLL") + "."
     }
 
     self.render = function (el) {
       var h2 = document.createElement("h2")
-      h2.textContent = "Übersicht"
+      h2.textContent = config.siteName
       el.appendChild(h2)
 
       var p = document.createElement("p")

+ 82 - 34
lib/proportions.js

@@ -1,12 +1,59 @@
 define(["chroma-js", "virtual-dom", "numeral-intl"],
   function (Chroma, V, numeral) {
 
-  return function () {
+  return function (config) {
     var self = this
-    var fwTable, hwTable, autoTable, gwTable
     var scale = Chroma.scale("YlGnBu").mode("lab")
 
-    function count(nodes, key, def, f) {
+    var statusTable = document.createElement("table")
+    statusTable.classList.add("proportion")
+
+    var fwTable = document.createElement("table")
+    fwTable.classList.add("proportion")
+
+    var hwTable = document.createElement("table")
+    hwTable.classList.add("proportion")
+
+    var geoTable = document.createElement("table")
+    geoTable.classList.add("proportion")
+
+    var autoTable = document.createElement("table")
+    autoTable.classList.add("proportion")
+
+    function showStatGlobal(o) {
+      var content, caption
+
+      if (o.thumbnail) {
+        content = document.createElement("img")
+        content.src = o.thumbnail
+      }
+
+      if (o.caption) {
+        caption = o.caption
+
+        if (!content)
+          content = document.createTextNode(caption)
+      }
+
+      var p = document.createElement("p")
+
+      if (o.href) {
+        var link = document.createElement("a")
+        link.target = "_blank"
+        link.href = o.href
+        link.appendChild(content)
+
+        if (caption && o.thumbnail)
+          link.title = caption
+
+        p.appendChild(link)
+      } else
+        p.appendChild(content)
+
+      return p
+    }
+
+    function count(nodes, key, f) {
       var dict = {}
 
       nodes.forEach( function (d) {
@@ -16,7 +63,7 @@ define(["chroma-js", "virtual-dom", "numeral-intl"],
           v = f(v)
 
         if (v === null)
-          v = def
+          return
 
         dict[v] = 1 + (v in dict ? dict[v] : 0)
       })
@@ -63,64 +110,65 @@ define(["chroma-js", "virtual-dom", "numeral-intl"],
         nodeDict[d.nodeinfo.node_id] = d
       })
 
-      var fwDict = count(nodes, ["nodeinfo", "software", "firmware", "release"], "n/a")
-      var hwDict = count(nodes, ["nodeinfo", "hardware", "model"], "n/a")
-      var autoDict = count(nodes, ["nodeinfo", "software", "autoupdater"], "deaktiviert", function (d) {
-        if (d === null || !d.enabled)
-          return null
-        else
-          return d.branch
+      var statusDict = count(nodes, ["flags", "online"], function (d) {
+        return d ? "online" : "offline"
       })
-
-      var gwDict = count(onlineNodes, ["statistics", "gateway"], "n/a", function (d) {
+      var fwDict = count(nodes, ["nodeinfo", "software", "firmware", "release"])
+      var hwDict = count(nodes, ["nodeinfo", "hardware", "model"])
+      var geoDict = count(nodes, ["nodeinfo", "location"], function (d) {
+        return d ? "ja" : "nein"
+      })
+      var autoDict = count(nodes, ["nodeinfo", "software", "autoupdater"], function (d) {
         if (d === null)
           return null
-
-        if (d in nodeDict)
-          return nodeDict[d].nodeinfo.hostname
-
-        return d
+        else if (d.enabled)
+          return d.branch
+        else
+          return "(deaktiviert)"
       })
 
+      fillTable(statusTable, statusDict.sort(function (a, b) { return b[1] - a[1] }))
       fillTable(fwTable, fwDict.sort(function (a, b) { return b[1] - a[1] }))
       fillTable(hwTable, hwDict.sort(function (a, b) { return b[1] - a[1] }))
+      fillTable(geoTable, geoDict.sort(function (a, b) { return b[1] - a[1] }))
       fillTable(autoTable, autoDict.sort(function (a, b) { return b[1] - a[1] }))
-      fillTable(gwTable, gwDict.sort(function (a, b) { return b[1] - a[1] }))
     }
 
     self.render = function (el) {
       var h2
       h2 = document.createElement("h2")
-      h2.textContent = "Firmwareversionen"
+      h2.textContent = "Status"
       el.appendChild(h2)
+      el.appendChild(statusTable)
 
-      fwTable = document.createElement("table")
-      fwTable.classList.add("proportion")
+      h2 = document.createElement("h2")
+      h2.textContent = "Firmwareversionen"
+      el.appendChild(h2)
       el.appendChild(fwTable)
 
       h2 = document.createElement("h2")
       h2.textContent = "Hardwaremodelle"
       el.appendChild(h2)
-
-      hwTable = document.createElement("table")
-      hwTable.classList.add("proportion")
       el.appendChild(hwTable)
 
       h2 = document.createElement("h2")
-      h2.textContent = "Autoupdater"
+      h2.textContent = "Auf der Karte sichtbar"
       el.appendChild(h2)
-
-      autoTable = document.createElement("table")
-      autoTable.classList.add("proportion")
-      el.appendChild(autoTable)
+      el.appendChild(geoTable)
 
       h2 = document.createElement("h2")
-      h2.textContent = "Gewählter Gateway"
+      h2.textContent = "Autoupdater"
       el.appendChild(h2)
+      el.appendChild(autoTable)
+
+      if (config.globalInfos)
+          config.globalInfos.forEach( function (globalInfo) {
+            h2 = document.createElement("h2")
+            h2.textContent = globalInfo.name
+            el.appendChild(h2)
 
-      gwTable = document.createElement("table")
-      gwTable.classList.add("proportion")
-      el.appendChild(gwTable)
+            el.appendChild(showStatGlobal(globalInfo))
+          })
     }
 
     return self

+ 81 - 35
lib/router.js

@@ -3,19 +3,27 @@ define(function () {
     var self = this
     var objects = { nodes: {}, links: {} }
     var targets = []
+    var views = {}
+    var currentView
+    var currentObject
     var running = false
 
-    function saveState(d) {
-      var s = "#!"
+    function saveState() {
+      var e = []
 
-      if (d) {
-        if ("node" in d)
-          s += "n:" + encodeURIComponent(d.node.nodeinfo.node_id)
+      if (currentView)
+        e.push("v:" + currentView)
 
-        if ("link" in d)
-          s += "l:" + encodeURIComponent(d.link.id)
+      if (currentObject) {
+        if ("node" in currentObject)
+          e.push("n:" + encodeURIComponent(currentObject.node.nodeinfo.node_id))
+
+        if ("link" in currentObject)
+          e.push("l:" + encodeURIComponent(currentObject.link.id))
       }
 
+      var s = "#!" + e.join(";")
+
       window.history.pushState(s, undefined, s)
     }
 
@@ -26,8 +34,10 @@ define(function () {
         t.resetView()
       })
 
-      if (push)
+      if (push) {
+        currentObject = undefined
         saveState()
+      }
     }
 
     function gotoNode(d) {
@@ -59,24 +69,38 @@ define(function () {
       if (!s.startsWith("#!"))
         return false
 
-      var args = s.slice(2).split(":")
-      var id
+      var targetSet = false
 
-      if (args[1] !== undefined) {
-        id = decodeURIComponent(args[1])
+      s.slice(2).split(";").forEach(function (d) {
+        var args = d.split(":")
 
-        if (args[0] === "n" && id in objects.nodes) {
-          gotoNode(objects.nodes[id])
-          return true
+        if (args[0] === "v" && args[1] in views) {
+          currentView = args[1]
+          views[args[1]]()
         }
 
-        if (args[0] === "l" && id in objects.links) {
-          gotoLink(objects.links[id])
-          return true
+        var id
+
+        if (args[0] === "n") {
+          id = decodeURIComponent(args[1])
+          if (id in objects.nodes) {
+            currentObject = { node: objects.nodes[id] }
+            gotoNode(objects.nodes[id])
+            targetSet = true
+          }
         }
-      }
 
-      return false
+        if (args[0] === "l") {
+          id = decodeURIComponent(args[1])
+          if (id in objects.links) {
+            currentObject = { link: objects.links[id] }
+            gotoLink(objects.links[id])
+            targetSet = true
+          }
+        }
+      })
+
+      return targetSet
     }
 
     self.start = function () {
@@ -91,12 +115,37 @@ define(function () {
       }
     }
 
+    self.view = function (d) {
+      if (d in views) {
+        views[d]()
+
+        if (!currentView || running)
+          currentView = d
+
+        if (!running)
+          return
+
+        saveState()
+
+        if (!currentObject) {
+          resetView(false)
+          return
+        }
+
+        if ("node" in currentObject)
+          gotoNode(currentObject.node)
+
+        if ("link" in currentObject)
+          gotoLink(currentObject.link)
+      }
+    }
+
     self.node = function (d) {
       return function () {
-        var sidebar = document.getElementById("sidebar")
-        sidebar.classList.remove("hidden")
-        if (gotoNode(d))
-          saveState({ node: d })
+        if (gotoNode(d)) {
+          currentObject = { node: d }
+          saveState()
+        }
 
         return false
       }
@@ -104,8 +153,10 @@ define(function () {
 
     self.link = function (d) {
       return function () {
-        if (gotoLink(d))
-          saveState({ link: d })
+        if (gotoLink(d)) {
+          currentObject = { link: d }
+          saveState()
+        }
 
         return false
       }
@@ -113,7 +164,6 @@ define(function () {
 
     self.reset = function () {
       resetView()
-      saveState()
     }
 
     self.addTarget = function (d) {
@@ -126,6 +176,10 @@ define(function () {
       })
     }
 
+    self.addView = function (k, d) {
+      views[k] = d
+    }
+
     self.setData = function (data) {
       objects.nodes = {}
       objects.links = {}
@@ -139,14 +193,6 @@ define(function () {
       })
     }
 
-    self.reload = function () {
-      if (!running)
-        return
-
-      if (!loadState(window.history.state))
-        resetView(false)
-    }
-
     return self
   }
 })

+ 12 - 2
lib/sidebar.js

@@ -4,8 +4,6 @@ define([], function () {
 
     var sidebar = document.createElement("div")
     sidebar.classList.add("sidebar")
-    sidebar.classList.add("hidden")
-    sidebar.id = "sidebar"
     el.appendChild(sidebar)
 
     var button = document.createElement("button")
@@ -32,6 +30,18 @@ define([], function () {
       d.render(container)
     }
 
+    self.ensureVisible = function () {
+      sidebar.classList.remove("hidden")
+    }
+
+    self.hide = function () {
+      container.classList.add("hidden")
+    }
+
+    self.reveal = function () {
+      container.classList.remove("hidden")
+    }
+
     self.container = sidebar
 
     return self

+ 1 - 4
lib/simplenodelist.js

@@ -1,5 +1,5 @@
 define(["moment", "virtual-dom"], function (moment, V) {
-  return function(config, nodes, field, router, title) {
+  return function(nodes, field, router, title) {
     var self = this
     var el, tbody
 
@@ -47,9 +47,6 @@ define(["moment", "virtual-dom"], function (moment, V) {
         if (has_location(d))
           td1Content.push(V.h("span", {className: "icon ion-location"}))
 
-        if ("owner" in d.nodeinfo && config.showContact)
-          td1Content.push(" - " + d.nodeinfo.owner.contact)
-
         var td1 = V.h("td", td1Content)
         var td2 = V.h("td", time)
 

+ 13 - 18
lib/tabs.js

@@ -8,14 +8,18 @@ define([], function () {
     var container = document.createElement("div")
 
     function gotoTab(li) {
-      for (var i = 0; i < tabs.children.length; i++) {
-        var el = tabs.children[i]
-        el.classList.remove("visible")
-        el.tab.classList.remove("visible")
-      }
+      for (var i = 0; i < tabs.children.length; i++)
+        tabs.children[i].classList.remove("visible")
+
+      while (container.firstChild)
+        container.removeChild(container.firstChild)
 
       li.classList.add("visible")
-      li.tab.classList.add("visible")
+
+      var tab = document.createElement("div")
+      tab.classList.add("tab")
+      container.appendChild(tab)
+      li.child.render(tab)
     }
 
     function switchTab() {
@@ -25,15 +29,10 @@ define([], function () {
     }
 
     self.add = function (title, d) {
-      var tab = document.createElement("div")
-      tab.classList.add("tab")
-      container.appendChild(tab)
-
       var li = document.createElement("li")
       li.textContent = title
       li.onclick = switchTab
-      tab.li = li
-      li.tab = tab
+      li.child = d
       tabs.appendChild(li)
 
       var anyVisible = false
@@ -44,12 +43,8 @@ define([], function () {
           break
         }
 
-      if (!anyVisible) {
-        tab.classList.add("visible")
-        li.classList.add("visible")
-      }
-
-      d.render(tab)
+      if (!anyVisible)
+        gotoTab(li)
     }
 
     self.render = function (el) {

+ 1 - 0
package.json

@@ -15,6 +15,7 @@
     "grunt-contrib-uglify": "^0.5.1",
     "grunt-contrib-watch": "^0.6.1",
     "grunt-eslint": "^10.0.0",
+    "grunt-bower-install-simple": "^1.1.2",
     "grunt-git-describe": "^2.3.2"
   },
   "eslintConfig": {

+ 1 - 1
scss/_forcegraph.scss

@@ -1,7 +1,7 @@
 .graph {
   height: 100%;
   width: 100%;
-  background: url(img/gplaypattern.png);
+  background: #2B2B2B;
 
   canvas {
     display: block;

+ 1 - 0
scss/_legend.scss

@@ -4,6 +4,7 @@
 	height: 1em;
 	border-radius: 50%;
 	display: inline-block;
+	vertical-align: -5%;
 }
 
 .legend-new .symbol

+ 0 - 16
scss/_map.scss

@@ -2,26 +2,10 @@
   paint-order: stroke;
 }
 
-.pick-coordinates {
-  cursor: crosshair;
-}
-
 .map {
   width: 100%;
   height: 100%;
 
-  button.locate-user:after {
-    content: '\f2a7';
-  }
-
-  button.add-layer:after {
-    content: '\f217';
-  }
-
-  button.show-coords:after {
-    content: '\f2a6';
-  }
-
  .node-alert {
     -webkit-animation: blink 2s linear;
     -webkit-animation-iteration-count: infinite;

+ 19 - 0
scss/_shadow.scss

@@ -0,0 +1,19 @@
+/* Original is in LESS and can be found here: https://gist.github.com/gefangenimnetz/3ef3e18364edf105c5af */
+
+@mixin shadow($level:1){
+  @if $level == 1 {
+    box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
+  }
+  @else if $level == 2 {
+    box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
+  }
+  @else if $level == 3 {
+    box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
+  }
+  @else if $level == 4 {
+    box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
+  }
+  @else if $level == 5 {
+    box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
+  }
+}

+ 49 - 45
scss/main.scss

@@ -1,4 +1,5 @@
 @import '_reset';
+@import '_shadow';
 @import '_base';
 @import '_leaflet';
 @import '_leaflet.label';
@@ -13,25 +14,28 @@ $buttondistance: 12pt;
 @import '_forcegraph';
 @import '_legend';
 
-.contenttoggle {
-  z-index: 100;
-  position: absolute;
-  top: $buttondistance;
-  right: $buttondistance;
-}
-
-.contenttoggle.next-graph:after {
-  content: '\f341';
-}
-
-.contenttoggle.next-map:after {
-  content: '\f203';
-}
-
 .content {
   position: fixed;
   width: 100%;
   height: 100vh;
+
+  .buttons {
+    direction: rtl;
+    unicode-bidi: bidi-override;
+
+    z-index: 100;
+    position: absolute;
+    top: $buttondistance;
+    right: $buttondistance;
+
+    button {
+      margin-left: $buttondistance;
+    }
+  }
+}
+
+.tabs, header {
+  background: rgba(0, 0, 0, 0.02);
 }
 
 .tabs {
@@ -40,8 +44,7 @@ $buttondistance: 12pt;
   list-style: none;
   display: flex;
   font-family: Roboto;
-  background: rgba(0, 0, 0, 0.02);
-  box-shadow: 0px 0.5px 3px rgba(0, 0, 0, 0.16), 0px 0.5px 2px rgba(0, 0, 0, 0.24);
+  @include shadow(1);
 }
 
 .tabs li {
@@ -62,14 +65,6 @@ $buttondistance: 12pt;
   color: #dc0067;
 }
 
-.tab {
-  display: none;
-}
-
-.tab.visible {
-  display: block;
-}
-
 body {
   margin: 0;
   padding: 0;
@@ -122,11 +117,15 @@ table.attributes td {
 
 .sidebar {
   .infobox, .container {
-    box-shadow: 0px 5px 20px rgba(0, 0, 0, 0.19), 0px 3px 6px rgba(0, 0, 0, 0.23);
-    background: rgba(255, 255, 255, 0.9);
+    @include shadow(2);
+    background: rgba(255, 255, 255, 0.97);
     border-radius: 2px;
   }
 
+  .container.hidden {
+    display: none;
+  }
+
   p {
     line-height: 1.67em;
   }
@@ -136,18 +135,23 @@ table.attributes td {
   font-family: "ionicons";
   color: #1566A9;
   word-spacing: -0.2em;
+  white-space: normal;
 }
 
 .infobox {
   position: relative;
   padding: 0.25em 0;
   margin-bottom: $buttondistance;
+
+  img {
+    max-width: 100%;
+  }
 }
 
 button {
   -webkit-tap-highlight-color: transparent;
   font-family: "ionicons";
-  box-shadow: 0px 0.5px 3px rgba(0, 0, 0, 0.16), 0px 0.5px 2px rgba(0, 0, 0, 0.24);
+  @include shadow(1);
   border-radius: 0.9em;
   background: rgba(255, 255, 255, 0.7);
   border: none;
@@ -166,7 +170,7 @@ button.active {
 button:hover {
   background: white;
   color: #dc0067;
-  box-shadow: 0px 5px 20px rgba(0, 0, 0, 0.19), 0px 3px 6px rgba(0, 0, 0, 0.23);
+  @include shadow(2);
 }
 
 button:active {
@@ -180,27 +184,22 @@ button::-moz-focus-inner {
 button.close {
   width: auto;
   height: auto;
-  font-size: 14pt;
+  font-size: 20pt;
   float: right;
-  padding: $buttondistance/2 $buttondistance;
   margin-right: $buttondistance;
   margin-top: $buttondistance;
   box-shadow: none;
   background: transparent;
   border-radius: 0;
   color: rgba(0, 0, 0, 0.5);
-  font-family: Roboto;
+  font-family: "ionicons";
 
   &:hover {
     color: #dc0067;
   }
 
-  &:active {
-    background: rgba(0, 0, 0, 0.04);
-  }
-
   &:after {
-    content: "CLOSE";
+    content: '\f2d7';
   }
 }
 
@@ -209,8 +208,17 @@ button.close {
   padding-right: $buttondistance;
 }
 
-.sidebar p, .sidebar table, .sidebar pre, .sidebar ul {
-  padding: 0 $buttondistance 1em;
+.sidebar {
+  p, pre, ul, h4 {
+    padding: 0 $buttondistance 1em;
+  }
+
+  table {
+    padding: 0 $buttondistance;
+  }
+  img {
+    max-width: 100%;
+  }
 }
 
 table {
@@ -344,7 +352,7 @@ table {
     margin: 0pt;
     width: $sidebarwidthsmall;
     min-height: 100vh;
-    box-shadow: 0px 5px 20px rgba(0, 0, 0, 0.19), 0px 3px 6px rgba(0, 0, 0, 0.23);
+    @include shadow(2);
     background: white;
 
     .sidebarhandle {
@@ -356,14 +364,10 @@ table {
       box-shadow: none;
       border-radius: 0;
     }
-
-    .infobox {
-      background: rgba(0, 0, 0, 0.02);
-    }
   }
 }
 
-@media screen and (max-width: 630pt) {
+@media screen and (max-width: $minscreenwidth) {
   .sidebarhandle {
     display: none;
   }

+ 15 - 0
tasks/build.js

@@ -1,5 +1,6 @@
 module.exports = function(grunt) {
   grunt.config.merge({
+    bowerdir: "bower_components",
     copy: {
       html: {
         options: {
@@ -74,6 +75,19 @@ module.exports = function(grunt) {
         }
       }
     },
+    "bower-install-simple": {
+        options: {
+          directory: "<%=bowerdir%>",
+          color: true,
+          interactive: false,
+          production: true
+        },
+        "prod": {
+          options: {
+            production: true
+          }
+        }
+      },
     requirejs: {
       compile: {
         options: {
@@ -89,6 +103,7 @@ module.exports = function(grunt) {
     }
   })
 
+  grunt.loadNpmTasks("grunt-bower-install-simple")
   grunt.loadNpmTasks("grunt-contrib-copy")
   grunt.loadNpmTasks("grunt-contrib-requirejs")
   grunt.loadNpmTasks("grunt-contrib-sass")

+ 1 - 1
tasks/development.js

@@ -13,7 +13,7 @@ module.exports = function (grunt) {
         options: {
           livereload: true
         },
-        files: ["*.css", "app.js", "lib/*.js", "*.html"],
+        files: ["*.css", "app.js", "lib/**/*.js", "*.html"],
         tasks: ["default"]
       },
       config: {