Browse Source

Merge remote-tracking branch 'ffhl/master' into ffhlmap

Michael Schwarz 9 years ago
parent
commit
00ee1c70e5
23 changed files with 886 additions and 349 deletions
  1. 1 0
      .gitignore
  2. 1 0
      .travis.yml
  3. 4 0
      CHANGELOG.md
  4. 17 1
      Gruntfile.js
  5. 27 6
      README.md
  6. 3 0
      app.js
  7. 3 1
      bower.json
  8. 0 0
      config.js.example
  9. 3 0
      html/index.html
  10. 1 0
      images
  11. 186 93
      lib/forcegraph.js
  12. 1 0
      lib/infobox/node.js
  13. 1 1
      lib/main.js
  14. 327 228
      lib/map.js
  15. 74 0
      lib/map/clientlayer.js
  16. 216 0
      lib/map/labelslayer.js
  17. 1 1
      lib/nodelist.js
  18. 1 1
      lib/sidebar.js
  19. 2 1
      package.json
  20. 0 14
      scss/_forcegraph.scss
  21. 4 0
      scss/_map.scss
  22. 2 2
      scss/main.scss
  23. 11 0
      tasks/build.js

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ bower_components/
 node_modules/
 build/
 .sass-cache/
+config.js

+ 1 - 0
.travis.yml

@@ -6,4 +6,5 @@ before_install:
 install:
   - npm install
   - bower install
+  - cp config.js.example config.js
 script: grunt

+ 4 - 0
CHANGELOG.md

@@ -11,7 +11,11 @@
 - Improved performance on Firefox
 - Labels in graph view
 - infobox: link to geouri with node's coordinates
+- infobox: show node id
 - map: locate user
+- map: adding custom layers from leaflet.providers
+- nodelist: sort by uptime fixed
+- graph: circles for clients
 
 ### Fixed bugs:
 

+ 17 - 1
Gruntfile.js

@@ -1,7 +1,23 @@
 module.exports = function (grunt) {
+  grunt.loadNpmTasks("grunt-git-describe")
+
+  grunt.initConfig({
+    "git-describe": {
+      options: {},
+      default: {}
+    }
+  })
+
+  grunt.registerTask("saveRevision", function() {
+    grunt.event.once("git-describe", function (rev) {
+      grunt.option("gitRevision", rev)
+    })
+    grunt.task.run("git-describe")
+  })
+
   grunt.loadTasks("tasks")
 
-  grunt.registerTask("default", ["lint", "copy", "sass", "requirejs"])
+  grunt.registerTask("default", ["lint", "saveRevision", "copy", "sass", "requirejs"])
   grunt.registerTask("lint", ["eslint"])
   grunt.registerTask("dev", ["default", "connect:server", "watch"])
 }

+ 27 - 6
README.md

@@ -21,18 +21,39 @@ Meshviewer is a frontend for
 - npm
 - bower
 - grunt-cli
-- Ruby and Sass
+- Sass (>= 3.2)
 
 # Installing dependencies
 
     npm install
     bower install
 
-# Building
+# Configure
+
+Copy `config.js.example` to `config.js` and change it to match your community.
+
+## dataPath (string)
+
+`dataPath` must point to a directory containing `nodes.json` and `graph.json`
+(both are generated by
+[ffmap-backend](https://github.com/ffnord/ffmap-backend)). Don't forget the
+trailing slash! Data may be served from a different domain with [CORS enabled].
+Also, GZip will greatly reduce bandwidth consumption.
+
+## siteName (string)
+
+Change this to match your communities' name. It will be used in various places.
 
-You may want to tweak `lib/config.js` to point to your data files. If it's
-served from a different domain, remember to [enable CORS] on your
-webserver. Enabling GZip will reduce bandwidth consumption.
+## mapSigmaScale (float)
+
+This affects the initial scale of the map. Greater values will show a larger
+area. Values like 1.0 and 0.5 might be good choices.
+
+## showContact (bool)
+
+Setting this to `false` will hide contact information for nodes.
+
+# Building
 
 Just run:
 
@@ -40,4 +61,4 @@ Just run:
 
 This will generate `build/` containing all required files.
 
-[enable CORS]: http://enable-cors.org/server.html
+[CORS enabled]: http://enable-cors.org/server.html

+ 3 - 0
app.js

@@ -3,6 +3,7 @@ require.config({
   paths: {
     "leaflet": "../bower_components/leaflet/dist/leaflet",
     "leaflet.label": "../bower_components/Leaflet.label/dist/leaflet.label",
+    "leaflet.providers": "../bower_components/leaflet-providers/leaflet-providers",
     "chroma-js": "../bower_components/chroma-js/chroma.min",
     "moment": "../bower_components/moment/min/moment-with-locales.min",
     "tablesort": "../bower_components/tablesort/tablesort.min",
@@ -11,10 +12,12 @@ require.config({
     "numeral": "../bower_components/numeraljs/min/numeral.min",
     "numeral-intl": "../bower_components/numeraljs/min/languages.min",
     "virtual-dom": "../bower_components/virtual-dom/dist/virtual-dom",
+    "rbush": "../bower_components/rbush/rbush",
     "helper": "../helper"
   },
   shim: {
     "leaflet.label": ["leaflet"],
+    "leaflet.providers": ["leaflet"],
     "tablesort": {
       exports: "Tablesort"
     },

+ 3 - 1
bower.json

@@ -22,7 +22,9 @@
     "d3": "~3.5.5",
     "numeraljs": "~1.5.3",
     "roboto-fontface": "~0.3.0",
-    "virtual-dom": "~2.0.1"
+    "virtual-dom": "~2.0.1",
+    "leaflet-providers": "~1.0.27",
+    "rbush": "https://github.com/mourner/rbush.git#~1.3.5"
   },
   "authors": [
     "Nils Schneider <nils@nilsschneider.net>"

+ 0 - 0
lib/config.js → config.js.example


+ 3 - 0
html/index.html

@@ -9,6 +9,9 @@
     <link rel="stylesheet" href="style.css">
     <script src="vendor/es6-shim/es6-shim.min.js"></script>
     <script src="app.js"></script>
+    <script>
+      console.log("Version: #revision#")
+    </script>
   </head>
   <body>
   </body>

+ 1 - 0
images

@@ -0,0 +1 @@
+bower_components/leaflet/dist/images

+ 186 - 93
lib/forcegraph.js

@@ -1,10 +1,11 @@
 define(["d3"], function (d3) {
   var margin = 200
+  var NODE_RADIUS = 15
+  var LINE_RADIUS = 12
 
   return function (config, linkScale, sidebar, router) {
     var self = this
-    var svg, canvas, ctx, screenRect
-    var svgNodes, svgLinks
+    var canvas, ctx, screenRect
     var nodesDict, linksDict
     var zoomBehavior
     var force
@@ -17,6 +18,9 @@ define(["d3"], function (d3) {
     var highlightedLinks = []
     var nodes = []
     var unknownNodes = []
+    var savedPanZoom
+
+    var draggedNode
 
     var LINK_DISTANCE = 70
 
@@ -42,20 +46,39 @@ define(["d3"], function (d3) {
         return d.o.id
     }
 
-    function dragstart(d) {
+    function dragstart() {
+      var e = translateXY(d3.mouse(el))
+
+      var nodes = intNodes.filter(function (d) {
+        return distancePoint(e, d) < NODE_RADIUS
+      })
+
+      if (nodes.length === 0)
+        return
+
+      draggedNode = nodes[0]
       d3.event.sourceEvent.stopPropagation()
-      d.fixed |= 2
+      d3.event.sourceEvent.preventDefault()
+      draggedNode.fixed |= 2
     }
 
-    function dragmove(d) {
-      d.px = d3.event.x
-      d.py = d3.event.y
-      force.resume()
+    function dragmove() {
+      if (draggedNode) {
+        var e = translateXY(d3.mouse(el))
+
+        draggedNode.px = e.x
+        draggedNode.py = e.y
+        force.resume()
+      }
     }
 
-    function dragend(d) {
-      d3.event.sourceEvent.stopPropagation()
-      d.fixed &= 1
+    function dragend() {
+      if (draggedNode) {
+        d3.event.sourceEvent.stopPropagation()
+        d3.event.sourceEvent.preventDefault()
+        draggedNode.fixed &= 1
+        draggedNode = undefined
+      }
     }
 
     var draggableNode = d3.behavior.drag()
@@ -96,15 +119,18 @@ define(["d3"], function (d3) {
 
     var translateP, scaleP
 
+    function onPanZoom() {
+      savedPanZoom = {translate: zoomBehavior.translate(),
+                      scale: zoomBehavior.scale()}
+      panzoom()
+    }
+
     function panzoom() {
       var translate = zoomBehavior.translate()
       var scale = zoomBehavior.scale()
 
 
       panzoomReal(translate, scale)
-
-      translateP = translate
-      scaleP = scale
     }
 
     function panzoomReal(translate, scale) {
@@ -112,10 +138,7 @@ define(["d3"], function (d3) {
                     right: (canvas.width - translate[0]) / scale,
                     bottom: (canvas.height - translate[1]) / scale}
 
-      svg.attr("transform", "translate(" + translate + ") " +
-                            "scale(" + scale + ")")
-
-      redraw()
+      requestAnimationFrame(redraw)
     }
 
     function getSize() {
@@ -180,52 +203,27 @@ define(["d3"], function (d3) {
         }
 
       if (!nopanzoom)
-        panzoomTo([0, 0], force.size())
-    }
-
-    function updateLinks(vis, data) {
-      var link = vis.selectAll("line")
-                    .data(data, function (d) { return d.o.id })
-
-      link.exit().remove()
-
-      link.enter().append("line")
-                  .on("click", function (d) {
-                    if (!d3.event.defaultPrevented)
-                      router.link(d.o)()
-                  })
-
-      return link
-    }
-
-    function updateNodes(vis, data) {
-      var node = vis.selectAll("circle")
-                    .data(data, function(d) { return d.o.id })
-
-      node.exit().remove()
-
-      node.enter().append("circle")
-          .attr("r", 12)
-          .on("click", function (d) {
-            if (!d3.event.defaultPrevented)
-              router.node(d.o.node)()
-          })
-          .call(draggableNode)
-
-      return node
+        if (!savedPanZoom)
+          panzoomTo([0, 0], force.size())
+        else
+          animatePanzoom(savedPanZoom.translate, savedPanZoom.scale)
     }
 
     function drawLabel(d) {
-      var sum = d.neighbours.reduce(function (a, b) {
-        return [a[0] + b.x, a[1] + b.y]
+      var neighbours = d.neighbours.filter(function (d) {
+        return !d.link.o.vpn
+      })
+
+      var sum = neighbours.reduce(function (a, b) {
+        return [a[0] + b.node.x, a[1] + b.node.y]
       }, [0, 0])
 
-      var sumCos = sum[0] - d.x * d.neighbours.length
-      var sumSin = sum[1] - d.y * d.neighbours.length
+      var sumCos = sum[0] - d.x * neighbours.length
+      var sumSin = sum[1] - d.y * neighbours.length
 
       var angle = Math.PI / 2
 
-      if (d.neighbours.length > 0)
+      if (neighbours.length > 0)
         angle = Math.PI + Math.atan2(sumSin, sumCos)
 
       var cos = Math.cos(angle)
@@ -279,6 +277,43 @@ define(["d3"], function (d3) {
                       xExtent[1] - xExtent[0] + 2 * margin,
                       yExtent[1] - yExtent[0] + 2 * margin)
 
+      // Remeber last translate/scale state
+      translateP = translate
+      scaleP = scale
+
+      ctx.beginPath()
+      nodes.filter(visibleNodes).forEach(function (d) {
+        var clients = d.o.node.statistics.clients
+        if (d.clients === 0)
+          return
+
+        var distance = 16
+        var radius = 3
+        var a = 1.2
+        var startAngle = Math.PI
+        var angle = startAngle
+
+        for (var i = 0; i < clients; i++) {
+          if ((angle - startAngle) > 2 * Math.PI) {
+            angle = startAngle
+            distance += 2 * radius * a
+          }
+
+          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)
+
+          var n = Math.floor((Math.PI * distance) / (a * radius))
+          var angleDelta = 2 * Math.PI / n
+          angle += angleDelta
+        }
+      })
+
+      ctx.fillStyle = "#73A7CC"
+      ctx.fill()
+
       if (highlightedLinks.length) {
         ctx.save()
         ctx.lineWidth = 10
@@ -294,16 +329,20 @@ define(["d3"], function (d3) {
         ctx.restore()
       }
 
-      ctx.lineWidth = 2.5
+      ctx.save()
 
       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()
       })
 
+      ctx.restore()
+
       if (scale > 0.9)
         intNodes.filter(visibleNodes).forEach(drawLabel, scale)
 
@@ -316,6 +355,7 @@ define(["d3"], function (d3) {
 
       ctx.strokeStyle = "#d00000"
       ctx.fillStyle = "#ffffff"
+      ctx.lineWidth = 2.5
 
       ctx.fill()
       ctx.stroke()
@@ -354,14 +394,6 @@ define(["d3"], function (d3) {
 
     function tickEvent() {
       redraw()
-
-      svgLinks.attr("x1", function(d) { return d.source.x })
-              .attr("y1", function(d) { return d.source.y })
-              .attr("x2", function(d) { return d.target.x })
-              .attr("y2", function(d) { return d.target.y })
-
-      svgNodes.attr("cx", function (d) { return d.x })
-              .attr("cy", function (d) { return d.y })
     }
 
     function resizeCanvas() {
@@ -372,7 +404,69 @@ define(["d3"], function (d3) {
       canvas.style.height = el.offsetHeight + "px"
       ctx.resetTransform()
       ctx.scale(r, r)
-      redraw()
+      requestAnimationFrame(redraw)
+    }
+
+    function distance(a, b) {
+      return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)
+    }
+
+    function distancePoint(a, b) {
+      return Math.sqrt(distance(a, b))
+    }
+
+    function distanceLink(p, a, b) {
+      /* http://stackoverflow.com/questions/849211 */
+
+      var l2 = distance(a, b)
+
+      if (l2 === 0)
+        return distance(p, a)
+
+      var t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2
+
+      if (t < 0)
+        return distance(p, a)
+
+      if (t > 1)
+        return distance(p, b)
+
+      return Math.sqrt(distance(p, { x: a.x + t * (b.x - a.x),
+                                     y: a.y + t * (b.y - a.y) }))
+    }
+
+    function translateXY(d) {
+      var translate = zoomBehavior.translate()
+      var scale = zoomBehavior.scale()
+
+      return {x: (d[0] - translate[0]) / scale,
+              y: (d[1] - translate[1]) / scale
+             }
+    }
+
+    function onClick() {
+      if (d3.event.defaultPrevented)
+        return
+
+      var e = translateXY(d3.mouse(el))
+
+      var nodes = intNodes.filter(function (d) {
+        return distancePoint(e, d) < NODE_RADIUS
+      })
+
+      if (nodes.length > 0) {
+        router.node(nodes[0].o.node)()
+        return
+      }
+
+      var links = intLinks.filter(function (d) {
+        return distanceLink(e, d.source, d.target) < LINE_RADIUS
+      })
+
+      if (links.length > 0) {
+        router.link(links[0].o)()
+        return
+      }
     }
 
     el = document.createElement("div")
@@ -381,28 +475,33 @@ define(["d3"], function (d3) {
 
     zoomBehavior = d3.behavior.zoom()
                      .scaleExtent([1 / 3, 3])
-                     .on("zoom", panzoom)
+                     .on("zoom", onPanZoom)
                      .translate([sidebar.getWidth(), 0])
 
-    canvas = d3.select(el).append("canvas").node()
-
-    svg = d3.select(el).append("svg")
-            .attr("pointer-events", "all")
-            .call(zoomBehavior)
-            .append("g")
-
-    var visLinks = svg.append("g")
-    var visNodes = svg.append("g")
+    canvas = d3.select(el)
+               .call(zoomBehavior)
+               .append("canvas")
+               .attr("pointer-events", "all")
+               .on("click", onClick)
+               .call(draggableNode)
+               .node()
 
     ctx = canvas.getContext("2d")
 
     force = d3.layout.force()
-              .charge(-80)
-              .gravity(0.01)
-              .chargeDistance(8 * LINK_DISTANCE)
-              .linkDistance(LINK_DISTANCE)
+              .charge(-250)
+              .gravity(0.1)
+              .linkDistance(function (d) {
+                if (d.o.vpn)
+                  return 0
+                else
+                  return LINK_DISTANCE
+              })
               .linkStrength(function (d) {
-                return Math.max(0.5, 1 / d.o.tq)
+                if (d.o.vpn)
+                  return 0
+                else
+                  return Math.max(0.5, 1 / d.o.tq)
               })
               .on("tick", tickEvent)
               .on("end", savePositions)
@@ -442,9 +541,7 @@ define(["d3"], function (d3) {
         oldLinks[d.o.id] = d
       })
 
-      intLinks = data.graph.links.filter( function (d) {
-        return !d.vpn
-      }).map( function (d) {
+      intLinks = data.graph.links.map( function (d) {
         var e
         if (d.id in oldLinks)
           e = oldLinks[d.id]
@@ -470,24 +567,24 @@ define(["d3"], function (d3) {
 
         var name = nodeName(d)
 
-        ctx.font = "11px Roboto"
         var offset = 8
         var lineWidth = 3
-        var width = ctx.measureText(name).width
         var buffer = document.createElement("canvas")
         var r = window.devicePixelRatio
         var bctx = buffer.getContext("2d")
+        bctx.font = "11px Roboto"
+        var width = bctx.measureText(name).width
         var scale = zoomBehavior.scaleExtent()[1] * r
         buffer.width = (width + 2 * lineWidth) * scale
         buffer.height = (16 + 2 * lineWidth) * scale
         bctx.scale(scale, scale)
         bctx.textBaseline = "middle"
         bctx.textAlign = "center"
-        bctx.font = ctx.font
         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.fillText(name, buffer.width / (2 * scale), buffer.height / (2 * scale))
 
@@ -499,8 +596,8 @@ define(["d3"], function (d3) {
       })
 
       intLinks.forEach(function (d) {
-        d.source.neighbours[d.target.o.id] = d.target
-        d.target.neighbours[d.source.o.id] = d.source
+        d.source.neighbours[d.target.o.id] = {node: d.target, link: d}
+        d.target.neighbours[d.source.o.id] = {node: d.source, link: d}
 
         if (d.o.source.node && d.o.target.node)
           linksDict[d.o.id] = d
@@ -512,9 +609,6 @@ define(["d3"], function (d3) {
         })
       })
 
-      svgLinks = updateLinks(visLinks, intLinks)
-      svgNodes = updateNodes(visNodes, intNodes)
-
       nodes = intNodes.filter(function (d) { return d.o.node })
       unknownNodes = intNodes.filter(function (d) { return !d.o.node })
 
@@ -570,7 +664,6 @@ define(["d3"], function (d3) {
       force.stop()
       canvas.remove()
       force = null
-      svg = null
     }
 
     return self

+ 1 - 0
lib/infobox/node.js

@@ -153,6 +153,7 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"],
 
     attributeEntry(attributes, "Hardware",  dictGet(d.nodeinfo, ["hardware", "model"]))
     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))
     attributeEntry(attributes, "Teil des Netzes", showFirstseen(d))

+ 1 - 1
lib/main.js

@@ -1,4 +1,4 @@
-define(["config", "moment", "router", "leaflet", "gui", "numeral"],
+define(["../config", "moment", "router", "leaflet", "gui", "numeral"],
 function (config, moment, Router, L, GUI, numeral) {
   return function () {
     function handleData(data) {

+ 327 - 228
lib/map.js

@@ -1,309 +1,408 @@
-define(["d3", "leaflet", "moment", "locationmarker", "leaflet.label"],
-  function (d3, L, moment, LocationMarker) {
-   var options = { worldCopyJump: true,
-                   zoomControl: false
-                 }
+define(["map/clientlayer", "map/labelslayer",
+        "d3", "leaflet", "moment", "locationmarker", "rbush",
+        "leaflet.label", "leaflet.providers"],
+  function (ClientLayer, LabelsLayer, d3, L, moment, LocationMarker, rbush) {
+    var options = { worldCopyJump: true,
+                    zoomControl: false
+                  }
 
-   var LocateButton = L.Control.extend({
-       options: {
-         position: "bottomright"
-       },
+    var AddLayerButton = L.Control.extend({
+        options: {
+          position: "bottomright"
+        },
 
-       active: false,
-       button: undefined,
+        initialize: function (f, options) {
+          L.Util.setOptions(this, options)
+          this.f = f
+        },
 
-       initialize: function (f, options) {
-         L.Util.setOptions(this, options)
-         this.f = f
-       },
+        onAdd: function () {
+          var button = L.DomUtil.create("button", "add-layer")
 
-       onAdd: function () {
-         var button = L.DomUtil.create("button", "locate-user")
+          L.DomEvent.disableClickPropagation(button)
+          L.DomEvent.addListener(button, "click", this.f, this)
 
-         L.DomEvent.disableClickPropagation(button)
-         L.DomEvent.addListener(button, "click", this.onClick, this)
+          this.button = button
 
-         this.button = button
+          return button
+        }
+    })
 
-         return button
-       },
+    var LocateButton = L.Control.extend({
+        options: {
+          position: "bottomright"
+        },
 
-       update: function() {
-         this.button.classList.toggle("active", this.active)
-       },
+        active: false,
+        button: undefined,
 
-       set: function(v) {
-         this.active = v
-         this.update()
-       },
+        initialize: function (f, options) {
+          L.Util.setOptions(this, options)
+          this.f = f
+        },
 
-       onClick: function () {
-         this.f(!this.active)
-       }
-    })
+        onAdd: function () {
+          var button = L.DomUtil.create("button", "locate-user")
 
-   function mkMarker(dict, iconFunc, router) {
-     return function (d) {
-       var m = L.circleMarker([d.nodeinfo.location.latitude, d.nodeinfo.location.longitude], iconFunc(d))
+          L.DomEvent.disableClickPropagation(button)
+          L.DomEvent.addListener(button, "click", this.onClick, this)
 
-       m.resetStyle = function () {
-         m.setStyle(iconFunc(d))
-       }
+          this.button = button
 
-       m.on("click", router.node(d))
-       m.bindLabel(d.nodeinfo.hostname)
+          return button
+        },
 
-       dict[d.nodeinfo.node_id] = m
+        update: function() {
+          this.button.classList.toggle("active", this.active)
+        },
 
-       return m
-     }
-   }
+        set: function(v) {
+          this.active = v
+          this.update()
+        },
 
-   function addLinksToMap(dict, linkScale, graph, router) {
-     graph = graph.filter( function (d) {
-       return "distance" in d && !d.vpn
-     })
+        onClick: function () {
+          this.f(!this.active)
+        }
+    })
 
-     var lines = graph.map( function (d) {
-       var opts = { color: linkScale(d.tq).hex(),
-                    weight: 4,
-                    opacity: 0.5,
-                    dashArray: "none"
-                  }
+    function mkMarker(dict, iconFunc, router) {
+      return function (d) {
+        var m = L.circleMarker([d.nodeinfo.location.latitude, d.nodeinfo.location.longitude], iconFunc(d))
 
-       var line = L.polyline(d.latlngs, opts)
+        m.resetStyle = function () {
+          m.setStyle(iconFunc(d))
+        }
 
-       line.resetStyle = function () {
-         line.setStyle(opts)
-       }
+        m.on("click", router.node(d))
+        m.bindLabel(d.nodeinfo.hostname)
 
-       line.bindLabel(d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname + "<br><strong>" + showDistance(d) + " / " + showTq(d) + "</strong>")
-       line.on("click", router.link(d))
+        dict[d.nodeinfo.node_id] = m
 
-       dict[d.id] = line
+        return m
+      }
+    }
 
-       return line
-     })
+    function addLinksToMap(dict, linkScale, graph, router) {
+      graph = graph.filter( function (d) {
+        return "distance" in d && !d.vpn
+      })
 
-     return lines
-   }
+      var lines = graph.map( function (d) {
+        var opts = { color: linkScale(d.tq).hex(),
+                     weight: 4,
+                     opacity: 0.5,
+                     dashArray: "none"
+                   }
 
-   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   = { 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 }
+        var line = L.polyline(d.latlngs, opts)
 
-   return function (config, linkScale, sidebar, router) {
-    var self = this
-    var barycenter
-    var groupOnline, groupOffline, groupNew, groupLost, groupLines
+        line.resetStyle = function () {
+          line.setStyle(opts)
+        }
 
-    var map, userLocation
+        line.bindLabel(d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname + "<br><strong>" + showDistance(d) + " / " + showTq(d) + "</strong>")
+        line.on("click", router.link(d))
 
-    var locateUserButton = new LocateButton(function (d) {
-      if (d)
-        enableTracking()
-      else
-        disableTracking()
-    })
+        dict[d.id] = line
 
-    function enableTracking() {
-      map.locate({watch: true,
-                  enableHighAccuracy: true,
-                  setView: true
-                 })
-      locateUserButton.set(true)
-    }
+        return line
+      })
 
-    function disableTracking() {
-      map.stopLocate()
-      locationError()
-      locateUserButton.set(false)
+      return lines
     }
 
-    function locationFound(e) {
-      if (!userLocation)
-        userLocation = new LocationMarker(e.latlng).addTo(map)
+    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   = { 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) {
+      var self = this
+      var barycenter
+      var groupOnline, groupOffline, groupNew, groupLost, groupLines
+      var savedView
+
+      var map, userLocation
+      var layerControl
+      var customLayers = new Set()
+
+      var locateUserButton = new LocateButton(function (d) {
+        if (d)
+          enableTracking()
+        else
+          disableTracking()
+      })
 
-      userLocation.setLatLng(e.latlng)
-      userLocation.setAccuracy(e.accuracy)
-    }
+      function saveView() {
+        savedView = {center: map.getCenter(),
+                     zoom: map.getZoom()}
+      }
 
-    function locationError() {
-      if (userLocation) {
-        map.removeLayer(userLocation)
-        userLocation = null
+      function enableTracking() {
+        map.locate({watch: true,
+                    enableHighAccuracy: true,
+                    setView: true
+                   })
+        locateUserButton.set(true)
       }
-    }
 
-    var el = document.createElement("div")
-    el.classList.add("map")
-    self.div = el
+      function disableTracking() {
+        map.stopLocate()
+        locationError()
+        locateUserButton.set(false)
+      }
 
-    map = L.map(el, options)
+      function locationFound(e) {
+        if (!userLocation)
+          userLocation = new LocationMarker(e.latlng).addTo(map)
 
-    L.tileLayer("https://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.jpg", {
-      subdomains: "1234",
-      type: "osm",
-      attribution: "Tiles &copy; <a href=\"https://www.mapquest.com/\" target=\"_blank\">MapQuest</a>, Data CC-BY-SA OpenStreetMap",
-      maxZoom: 18
-    }).addTo(map)
+        userLocation.setLatLng(e.latlng)
+        userLocation.setAccuracy(e.accuracy)
+      }
 
-    map.on("locationfound", locationFound)
-    map.on("locationerror", locationError)
+      function locationError() {
+        if (userLocation) {
+          map.removeLayer(userLocation)
+          userLocation = null
+        }
+      }
 
-    map.addControl(locateUserButton)
+      function addLayer(layerName) {
+        if (customLayers.has(layerName))
+          return
 
-    var nodeDict = {}
-    var linkDict = {}
-    var highlight
+        try {
+          var layer = L.tileLayer.provider(layerName)
+          layerControl.addBaseLayer(layer, layerName)
+          customLayers.add(layerName)
 
-    function resetMarkerStyles(nodes, links) {
-      Object.keys(nodes).forEach( function (d) {
-        nodes[d].resetStyle()
-      })
+          if (!layerControl.added) {
+            layerControl.addTo(map)
+            layerControl.added = true
+          }
 
-      Object.keys(links).forEach( function (d) {
-        links[d].resetStyle()
-      })
-    }
+          if (localStorageTest())
+            localStorage.setItem("map/customLayers", JSON.stringify(Array.from(customLayers)))
+        } catch (e) {
+          return
+        }
+      }
 
-    function setView(bounds) {
-      map.fitBounds(bounds, {paddingTopLeft: [sidebar.getWidth(), 0]})
-    }
+      var el = document.createElement("div")
+      el.classList.add("map")
+      self.div = el
 
-    function resetZoom() {
-      setView(barycenter.getBounds())
-    }
+      map = L.map(el, options)
 
-    function goto(m) {
-      var bounds
+      var baseLayer = L.tileLayer("https://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.jpg", {
+                                    subdomains: "1234",
+                                    type: "osm",
+                                    attribution: "Tiles &copy; <a href=\"https://www.mapquest.com/\" target=\"_blank\">MapQuest</a>, Data CC-BY-SA OpenStreetMap",
+                                    maxZoom: 18
+                                  }).addTo(map)
 
-      if ("getBounds" in m)
-        bounds = m.getBounds()
-      else
-        bounds = L.latLngBounds([m.getLatLng()])
+      map.on("locationfound", locationFound)
+      map.on("locationerror", locationError)
+      map.on("dragend", saveView)
 
-      setView(bounds)
+      map.addControl(locateUserButton)
 
-      return m
-    }
+      map.addControl(new AddLayerButton(function () {
+        /*eslint no-alert:0*/
+        var layerName = prompt("Leaflet Provider:")
+        addLayer(layerName)
+      }))
 
-    function updateView(nopanzoom) {
-      resetMarkerStyles(nodeDict, linkDict)
-      var m
+      layerControl = L.control.layers({"MapQuest": baseLayer}, [], {position: "bottomright"})
 
-      if (highlight !== undefined)
-        if (highlight.type === "node") {
-          m = nodeDict[highlight.o.nodeinfo.node_id]
+      if (localStorageTest()) {
+        var layers = JSON.parse(localStorage.getItem("map/customLayers"))
 
-          if (m)
-            m.setStyle({ color: "orange", weight: 20, fillOpacity: 1, opacity: 0.7, className: "stroke-first" })
-        } else if (highlight.type === "link") {
-          m = linkDict[highlight.o.id]
+        if (layers)
+          layers.forEach(addLayer)
+      }
 
-          if (m)
-            m.setStyle({ weight: 7, opacity: 1, dashArray: "10, 10" })
-        }
+      var clientLayer = new ClientLayer()
+      clientLayer.addTo(map)
+      clientLayer.setZIndex(5)
+
+      var labelsLayer = new LabelsLayer()
+      labelsLayer.addTo(map)
+      labelsLayer.setZIndex(6)
+
+      var nodeDict = {}
+      var linkDict = {}
+      var highlight
 
-      if (!nopanzoom)
-        if (m)
-          goto(m)
+      function resetMarkerStyles(nodes, links) {
+        Object.keys(nodes).forEach( function (d) {
+          nodes[d].resetStyle()
+        })
+
+        Object.keys(links).forEach( function (d) {
+          links[d].resetStyle()
+        })
+      }
+
+      function setView(bounds) {
+        map.fitBounds(bounds, {paddingTopLeft: [sidebar.getWidth(), 0]})
+      }
+
+      function resetZoom() {
+        setView(barycenter.getBounds())
+      }
+
+      function goto(m) {
+        var bounds
+
+        if ("getBounds" in m)
+          bounds = m.getBounds()
         else
-          resetZoom()
-    }
+          bounds = L.latLngBounds([m.getLatLng()])
 
-    function calcBarycenter(nodes) {
-      nodes = nodes.map(function (d) { return d.nodeinfo.location })
-      var lats = nodes.map(function (d) { return d.latitude })
-      var lngs = nodes.map(function (d) { return d.longitude })
+        setView(bounds)
 
-      var barycenter = L.latLng(d3.median(lats), d3.median(lngs))
-      var barycenterDev = [d3.deviation(lats), d3.deviation(lngs)]
+        return m
+      }
 
-      var barycenterCircle = L.latLng(barycenter.lat + barycenterDev[0],
-                                      barycenter.lng + barycenterDev[1])
+      function updateView(nopanzoom) {
+        resetMarkerStyles(nodeDict, linkDict)
+        var m
 
-      var r = barycenter.distanceTo(barycenterCircle)
+        if (highlight !== undefined)
+          if (highlight.type === "node") {
+            m = nodeDict[highlight.o.nodeinfo.node_id]
 
-      return L.circle(barycenter, r * config.mapSigmaScale)
-    }
+            if (m)
+              m.setStyle({ color: "orange", weight: 20, fillOpacity: 1, opacity: 0.7, className: "stroke-first" })
+          } else if (highlight.type === "link") {
+            m = linkDict[highlight.o.id]
+
+            if (m)
+              m.setStyle({ weight: 7, opacity: 1, dashArray: "10, 10" })
+          }
 
-    self.setData = function (data) {
-      nodeDict = {}
-      linkDict = {}
+        if (!nopanzoom)
+          if (m)
+            goto(m)
+          else if (savedView)
+            map.setView(savedView.center, savedView.zoom)
+          else
+            resetZoom()
+      }
 
-      if (groupOffline)
-        groupOffline.clearLayers()
+      function calcBarycenter(nodes) {
+        nodes = nodes.map(function (d) { return d.nodeinfo.location })
+        var lats = nodes.map(function (d) { return d.latitude })
+        var lngs = nodes.map(function (d) { return d.longitude })
 
-      if (groupOnline)
-        groupOnline.clearLayers()
+        var barycenter = L.latLng(d3.median(lats), d3.median(lngs))
+        var barycenterDev = [d3.deviation(lats), d3.deviation(lngs)]
 
-      if (groupNew)
-        groupNew.clearLayers()
+        var barycenterCircle = L.latLng(barycenter.lat + barycenterDev[0],
+                                        barycenter.lng + barycenterDev[1])
 
-      if (groupLost)
-        groupLost.clearLayers()
+        var r = barycenter.distanceTo(barycenterCircle)
 
-      if (groupLines)
-        groupLines.clearLayers()
+        return L.circle(barycenter, r * config.mapSigmaScale)
+      }
 
-      var lines = addLinksToMap(linkDict, linkScale, data.graph.links, router)
-      groupLines = L.featureGroup(lines).addTo(map)
+      function mapRTree(d) {
+        var o = [ d.nodeinfo.location.latitude, d.nodeinfo.location.longitude,
+                  d.nodeinfo.location.latitude, d.nodeinfo.location.longitude]
 
-      barycenter = calcBarycenter(data.nodes.all.filter(has_location))
+        o.node = d
 
-      var nodesOnline = subtract(data.nodes.all.filter(online), data.nodes.new)
-      var nodesOffline = subtract(data.nodes.all.filter(offline), data.nodes.lost)
+        return o
+      }
 
-      var markersOnline = nodesOnline.filter(has_location)
-        .map(mkMarker(nodeDict, function () { return iconOnline }, router))
+      self.setData = function (data) {
+        nodeDict = {}
+        linkDict = {}
 
-      var markersOffline = nodesOffline.filter(has_location)
-        .map(mkMarker(nodeDict, function () { return iconOffline }, router))
+        if (groupOffline)
+          groupOffline.clearLayers()
 
-      var markersNew = data.nodes.new.filter(has_location)
-        .map(mkMarker(nodeDict, function () { return iconNew }, router))
+        if (groupOnline)
+          groupOnline.clearLayers()
 
-      var markersLost = data.nodes.lost.filter(has_location)
-        .map(mkMarker(nodeDict, function (d) {
-          if (d.lastseen.isAfter(moment(data.now).subtract(3, "days")))
-            return iconAlert
+        if (groupNew)
+          groupNew.clearLayers()
 
-          return iconLost
-        }, router))
+        if (groupLost)
+          groupLost.clearLayers()
 
-      groupOffline = L.featureGroup(markersOffline).addTo(map)
-      groupOnline = L.featureGroup(markersOnline).addTo(map)
-      groupNew = L.featureGroup(markersNew).addTo(map)
-      groupLost = L.featureGroup(markersLost).addTo(map)
+        if (groupLines)
+          groupLines.clearLayers()
 
-      updateView(true)
-    }
+        var lines = addLinksToMap(linkDict, linkScale, data.graph.links, router)
+        groupLines = L.featureGroup(lines).addTo(map)
 
-    self.resetView = function () {
-      disableTracking()
-      highlight = undefined
-      updateView()
-    }
+        barycenter = calcBarycenter(data.nodes.all.filter(has_location))
 
-    self.gotoNode = function (d) {
-      disableTracking()
-      highlight = {type: "node", o: d}
-      updateView()
-    }
+        var nodesOnline = subtract(data.nodes.all.filter(online), data.nodes.new)
+        var nodesOffline = subtract(data.nodes.all.filter(offline), data.nodes.lost)
 
-    self.gotoLink = function (d) {
-      disableTracking()
-      highlight = {type: "link", o: d}
-      updateView()
-    }
+        var markersOnline = nodesOnline.filter(has_location)
+          .map(mkMarker(nodeDict, function () { return iconOnline }, router))
 
-    self.destroy = function () {
-      map.remove()
-    }
+        var markersOffline = nodesOffline.filter(has_location)
+          .map(mkMarker(nodeDict, function () { return iconOffline }, router))
+
+        var markersNew = data.nodes.new.filter(has_location)
+          .map(mkMarker(nodeDict, function () { return iconNew }, router))
 
-    return self
-  }
+        var markersLost = data.nodes.lost.filter(has_location)
+          .map(mkMarker(nodeDict, function (d) {
+            if (d.lastseen.isAfter(moment(data.now).subtract(3, "days")))
+              return iconAlert
+
+            return iconLost
+          }, router))
+
+        groupOffline = L.featureGroup(markersOffline).addTo(map)
+        groupOnline = L.featureGroup(markersOnline).addTo(map)
+        groupNew = L.featureGroup(markersNew).addTo(map)
+        groupLost = L.featureGroup(markersLost).addTo(map)
+
+        var rtreeOnlineAll = rbush(9)
+
+        rtreeOnlineAll.load(data.nodes.all.filter(online).filter(has_location).map(mapRTree))
+
+        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)
+                            })
+
+        updateView(true)
+      }
+
+      self.resetView = function () {
+        disableTracking()
+        highlight = undefined
+        updateView()
+      }
+
+      self.gotoNode = function (d) {
+        disableTracking()
+        highlight = {type: "node", o: d}
+        updateView()
+      }
+
+      self.gotoLink = function (d) {
+        disableTracking()
+        highlight = {type: "link", o: d}
+        updateView()
+      }
+
+      self.destroy = function () {
+        map.remove()
+      }
+
+      return self
+    }
 })

+ 74 - 0
lib/map/clientlayer.js

@@ -0,0 +1,74 @@
+define(["leaflet"],
+  function (L) {
+    return L.TileLayer.Canvas.extend({
+      setData: function (d) {
+        this.data = d
+        this.redraw()
+      },
+      drawTile: function (canvas, tilePoint) {
+        function getTileBBox(s, map, tileSize, margin) {
+          var tl = map.unproject([s.x - margin, s.y - margin])
+          var br = map.unproject([s.x + margin + tileSize, s.y + margin + tileSize])
+
+          return [br.lat, tl.lng, tl.lat, br.lng]
+        }
+
+        if (!this.data)
+          return
+
+        var tileSize = this.options.tileSize
+        var s = tilePoint.multiplyBy(tileSize)
+        var map = this._map
+
+        var margin = 50
+        var bbox = getTileBBox(s, map, tileSize, margin)
+
+        var nodes = this.data.search(bbox)
+
+        if (nodes.length === 0)
+          return
+
+        var ctx = canvas.getContext("2d")
+
+        var radius = 3
+        var a = 1.2
+        var startDistance = 12
+        var startAngle = Math.PI
+
+        ctx.beginPath()
+        nodes.forEach(function (d) {
+          var p = map.project([d.node.nodeinfo.location.latitude, d.node.nodeinfo.location.longitude])
+          var clients = d.node.statistics.clients
+
+          if (clients === 0)
+            return
+
+          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)
+
+            ctx.moveTo(x, y)
+            ctx.arc(x, y, radius, 0, 2 * Math.PI)
+
+            var n = Math.floor((Math.PI * distance) / (a * radius))
+            var angleDelta = 2 * Math.PI / n
+            angle += angleDelta
+          }
+        })
+
+        ctx.fillStyle = "rgba(153, 118, 16, 0.5)"
+        ctx.fill()
+      }
+    })
+})

+ 216 - 0
lib/map/labelslayer.js

@@ -0,0 +1,216 @@
+define(["leaflet", "rbush"],
+  function (L, rbush) {
+    var labelLocations = [["left",   "middle",      0 / 8],
+                          ["center", "top",         6 / 8],
+                          ["right",  "middle",      4 / 8],
+                          ["left",   "top",         7 / 8],
+                          ["left",   "ideographic", 1 / 8],
+                          ["right",  "top",         5 / 8],
+                          ["center", "ideographic", 2 / 8],
+                          ["right",  "ideographic", 3 / 8]]
+
+    var labelOffset = 8
+    var font = "10px Roboto"
+    var labelHeight = 12
+    var nodeRadius = 4
+
+    var ctx = document.createElement("canvas").getContext("2d")
+
+    function measureText(font, text) {
+      ctx.font = font
+      return ctx.measureText(text)
+    }
+
+    function mapRTree(d) {
+      var o = [d.position.lat, d.position.lng, d.position.lat, d.position.lng]
+
+      o.label = d
+
+      return o
+    }
+
+    function prepareLabel(fillStyle) {
+      return function (d) {
+        return { position: L.latLng(d.nodeinfo.location.latitude, d.nodeinfo.location.longitude),
+                 label: d.nodeinfo.hostname,
+                 fillStyle: fillStyle,
+                 height: labelHeight,
+                 width: measureText(font, d.nodeinfo.hostname).width
+               }
+      }
+    }
+
+    function calcOffset(loc) {
+      return [ labelOffset * Math.cos(loc[2] * 2 * Math.PI),
+              -labelOffset * Math.sin(loc[2] * 2 * Math.PI)]
+    }
+
+    function labelRect(p, offset, anchor, label) {
+      var dx = { left: 0,
+                 right: -label.width,
+                 center: -label.width /  2
+               }
+
+      var dy = { top: 0,
+                 ideographic: -label.height,
+                 middle: -label.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]
+    }
+
+    var c = L.TileLayer.Canvas.extend({
+      onAdd: function (map) {
+        L.TileLayer.Canvas.prototype.onAdd.call(this, map)
+        if (this.data)
+          this.prepareLabels()
+      },
+      setData: function (d) {
+        this.data = d
+        if (this._map)
+          this.prepareLabels()
+      },
+      prepareLabels: function () {
+        var d = this.data
+
+        // label:
+        // - position (WGS84 coords)
+        // - offset (2D vector in pixels)
+        // - anchor (tuple, textAlignment, textBaseline)
+        // - minZoom (inclusive)
+        // - label (string)
+        // - color (string)
+
+        var labelsOnline = d.online.map(prepareLabel("rgba(0, 0, 0, 0.9)"))
+        var labelsOffline = d.offline.map(prepareLabel("rgba(212, 62, 42, 0.9)"))
+        var labelsNew = d.new.map(prepareLabel("rgba(85, 128, 32, 0.9)"))
+        var labelsLost = d.lost.map(prepareLabel("rgba(212, 62, 42, 0.9)"))
+
+        var labels = []
+                     .concat(labelsNew)
+                     .concat(labelsLost)
+                     .concat(labelsOnline)
+                     .concat(labelsOffline)
+
+        var minZoom = this.options.minZoom
+        var maxZoom = this.options.maxZoom
+
+        var trees = []
+
+        var map = this._map
+
+        function nodeToRect(z) {
+          return function (d) {
+            var p = map.project(d.position, z)
+            return [p.x - nodeRadius, p.y - nodeRadius,
+                    p.x + nodeRadius, p.y + nodeRadius]
+          }
+        }
+
+        for (var z = minZoom; z <= maxZoom; z++) {
+          trees[z] = rbush(9)
+          trees[z].load(labels.map(nodeToRect(z)))
+        }
+
+        labels.forEach(function (d) {
+          var best = labelLocations.map(function (loc) {
+            var offset = calcOffset(loc, d)
+            var z
+
+            for (z = maxZoom; z >= minZoom; z--) {
+              var p = map.project(d.position, z)
+              var rect = labelRect(p, offset, loc, d)
+              var candidates = trees[z].search(rect)
+
+              if (candidates.length > 0)
+                break
+            }
+
+            return {loc: loc, z: z + 1}
+          }).filter(function (d) {
+            return d.z <= maxZoom
+          }).sort(function (a, b) {
+            return a.z - b.z
+          })[0]
+
+          if (best === undefined)
+            return
+
+          d.offset = calcOffset(best.loc, d)
+          d.minZoom = best.z
+          d.anchor = best.loc
+
+          for (var z = maxZoom; z >= best.z; z--) {
+            var p = map.project(d.position, z)
+            var rect = labelRect(p, d.offset, best.loc, d)
+            trees[z].insert(rect)
+          }
+        })
+
+        labels = labels.filter(function (d) {
+          return d.minZoom !== undefined
+        })
+
+        this.margin = 16 + labels.map(function (d) {
+          return d.width
+        }).sort().reverse()[0]
+
+        this.labels = rbush(9)
+        this.labels.load(labels.map(mapRTree))
+
+        this.redraw()
+      },
+      drawTile: function (canvas, tilePoint, zoom) {
+        function getTileBBox(s, map, tileSize, margin) {
+          var tl = map.unproject([s.x - margin, s.y - margin])
+          var br = map.unproject([s.x + margin + tileSize, s.y + margin + tileSize])
+
+          return [br.lat, tl.lng, tl.lat, br.lng]
+        }
+
+        if (!this.labels)
+          return
+
+        var tileSize = this.options.tileSize
+        var s = tilePoint.multiplyBy(tileSize)
+        var map = this._map
+
+        function projectNodes(d) {
+          var p = map.project(d.label.position)
+
+          p.x -= s.x
+          p.y -= s.y
+
+          return {p: p, label: d.label}
+        }
+
+        var bbox = getTileBBox(s, map, tileSize, this.margin)
+
+        var labels = this.labels.search(bbox).map(projectNodes)
+
+        var ctx = canvas.getContext("2d")
+
+        ctx.font = font
+        ctx.lineWidth = 5
+        ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"
+        ctx.miterLimit = 2
+
+        function drawLabel(d) {
+          ctx.textAlign = d.label.anchor[0]
+          ctx.textBaseline = d.label.anchor[1]
+          ctx.fillStyle = d.label.fillStyle
+          ctx.strokeText(d.label.label, d.p.x + d.label.offset[0], d.p.y + d.label.offset[1])
+          ctx.fillText(d.label.label, d.p.x + d.label.offset[0], d.p.y + d.label.offset[1])
+        }
+
+        labels.filter(function (d) {
+          return zoom >= d.label.minZoom
+        }).forEach(drawLabel)
+      }
+    })
+
+    return c
+})

+ 1 - 1
lib/nodelist.js

@@ -3,7 +3,7 @@ define(["sorttable", "virtual-dom", "numeral"], function (SortTable, V, numeral)
     if (d.flags.online && "uptime" in d.statistics)
       return Math.round(d.statistics.uptime)
     else if (!d.flags.online && "lastseen" in d)
-      return Math.round(-(now - d.lastseen) / 3600)
+      return Math.round(-(now.unix() - d.lastseen.unix()))
   }
 
   function showUptime(uptime) {

+ 1 - 1
lib/sidebar.js

@@ -22,7 +22,7 @@ define([], function () {
       if (sidebar.classList.contains("hidden"))
           return 0
 
-      var small = window.matchMedia("(max-width: 60em)")
+      var small = window.matchMedia("(max-width: 630pt)")
       return small.matches ? 0 : sidebar.offsetWidth
     }
 

+ 2 - 1
package.json

@@ -14,7 +14,8 @@
     "grunt-contrib-sass": "^0.9.2",
     "grunt-contrib-uglify": "^0.5.1",
     "grunt-contrib-watch": "^0.6.1",
-    "grunt-eslint": "^10.0.0"
+    "grunt-eslint": "^10.0.0",
+    "grunt-git-describe": "^2.3.2"
   },
   "eslintConfig": {
     "env": {

+ 0 - 14
scss/_forcegraph.scss

@@ -5,20 +5,6 @@
 
   canvas {
     display: block;
-  }
-
-  svg {
-    display: block;
-    width: 100%;
-    height: 100%;
     position: absolute;
-    top: 0;
-    left: 0;
-
-    circle, line {
-      opacity: 0;
-      stroke-width: 16px;
-      cursor: pointer;
-    }
   }
 }  

+ 4 - 0
scss/_map.scss

@@ -10,6 +10,10 @@
     content: '\f2a7';
   }
 
+  button.add-layer:after {
+    content: '\f217';
+  }
+
  .node-alert {
     -webkit-animation: blink 2s linear;
     -webkit-animation-iteration-count: infinite;

+ 2 - 2
scss/main.scss

@@ -3,9 +3,9 @@
 @import '_leaflet';
 @import '_leaflet.label';
 
-$minscreenwidth: 60em;
+$minscreenwidth: 630pt;
 $sidebarwidth: 420pt;
-$sidebarwidthsmall: 360pt;
+$sidebarwidthsmall: 320pt;
 $buttondistance: 12pt;
 
 @import '_sidebar';

+ 11 - 0
tasks/build.js

@@ -2,6 +2,11 @@ module.exports = function(grunt) {
   grunt.config.merge({
     copy: {
       html: {
+        options: {
+          process: function (content) {
+            return content.replace("#revision#", grunt.option("gitRevision"))
+          }
+        },
         src: ["*.html"],
         expand: true,
         cwd: "html/",
@@ -41,6 +46,12 @@ module.exports = function(grunt) {
         expand: true,
         dest: "build/",
         cwd: "bower_components/ionicons/"
+      },
+      leafletImages: {
+        src: [ "images/*" ],
+        expand: true,
+        dest: "build/",
+        cwd: "bower_components/leaflet/dist/"
       }
     },
     sass: {