├── scss ├── _sidebar.scss ├── _leaflet.scss ├── _leaflet.label.scss ├── _forcegraph.scss ├── _base.scss ├── _legend.scss ├── _shadow.scss ├── _map.scss ├── _reset.scss └── main.scss ├── style.css ├── images ├── Gemfile ├── doc ├── links.png ├── mapview.png ├── allnodes.png ├── graphview.png └── statistics.png ├── .gitignore ├── .travis.yml ├── Gemfile.lock ├── tasks ├── clean.js ├── development.js ├── linting.js └── build.js ├── lib ├── container.js ├── title.js ├── infobox │ ├── link.js │ ├── main.js │ └── node.js ├── sidebar.js ├── tabs.js ├── about.js ├── legend.js ├── sorttable.js ├── locationmarker.js ├── meshstats.js ├── simplenodelist.js ├── linklist.js ├── map │ ├── clientlayer.js │ └── labelslayer.js ├── nodelist.js ├── gui.js ├── main.js ├── router.js ├── proportions.js ├── map.js └── forcegraph.js ├── html └── index.html ├── config.json.example ├── Gruntfile.js ├── index.html ├── bower.json ├── package.json ├── app.js ├── CHANGELOG.md ├── helper.js ├── README.md └── LICENSE /scss/_sidebar.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | build/style.css -------------------------------------------------------------------------------- /images: -------------------------------------------------------------------------------- 1 | bower_components/leaflet/dist/images -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem "sass" -------------------------------------------------------------------------------- /scss/_leaflet.scss: -------------------------------------------------------------------------------- 1 | ../bower_components/leaflet/dist/leaflet.css -------------------------------------------------------------------------------- /doc/links.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffnord/meshviewer/HEAD/doc/links.png -------------------------------------------------------------------------------- /doc/mapview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffnord/meshviewer/HEAD/doc/mapview.png -------------------------------------------------------------------------------- /scss/_leaflet.label.scss: -------------------------------------------------------------------------------- 1 | ../bower_components/Leaflet.label/dist/leaflet.label.css -------------------------------------------------------------------------------- /doc/allnodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffnord/meshviewer/HEAD/doc/allnodes.png -------------------------------------------------------------------------------- /doc/graphview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffnord/meshviewer/HEAD/doc/graphview.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ 3 | build/ 4 | .sass-cache/ 5 | config.js 6 | -------------------------------------------------------------------------------- /doc/statistics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ffnord/meshviewer/HEAD/doc/statistics.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_install: 3 | - gem install sass 4 | - npm install -g grunt-cli 5 | install: 6 | - npm install 7 | script: grunt 8 | -------------------------------------------------------------------------------- /scss/_forcegraph.scss: -------------------------------------------------------------------------------- 1 | .graph { 2 | height: 100%; 3 | width: 100%; 4 | background: #2B2B2B; 5 | 6 | canvas { 7 | display: block; 8 | position: absolute; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | sass (3.4.16) 5 | 6 | PLATFORMS 7 | ruby 8 | 9 | DEPENDENCIES 10 | sass 11 | 12 | BUNDLED WITH 13 | 1.10.6 14 | -------------------------------------------------------------------------------- /tasks/clean.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.config.merge({ 3 | clean: { 4 | build: ["build/**/*", "node_modules/grunt-newer/.cache"] 5 | } 6 | }) 7 | 8 | grunt.loadNpmTasks("grunt-contrib-clean") 9 | } 10 | -------------------------------------------------------------------------------- /lib/container.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | return function (tag) { 3 | if (!tag) 4 | tag = "div" 5 | 6 | var self = this 7 | 8 | var container = document.createElement(tag) 9 | 10 | self.add = function (d) { 11 | d.render(container) 12 | } 13 | 14 | self.render = function (el) { 15 | el.appendChild(container) 16 | } 17 | 18 | return self 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /scss/_base.scss: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, h5, h6 { 2 | font-weight: bold; 3 | } 4 | 5 | h1 { 6 | padding: 0.67em 0; 7 | font-size: 2em; 8 | } 9 | 10 | h2 { 11 | padding: 0.83em 0; 12 | font-size: 1.5em; 13 | } 14 | 15 | h3 { 16 | padding: 1em 0; 17 | font-size: 1.17em; 18 | } 19 | 20 | h4 { 21 | font-size: 1em; 22 | } 23 | 24 | h5 { 25 | font-size: 0.83em; 26 | } 27 | 28 | h6 { 29 | font-size: 0.67em; 30 | } 31 | -------------------------------------------------------------------------------- /scss/_legend.scss: -------------------------------------------------------------------------------- 1 | .legend .symbol 2 | { 3 | width: 1em; 4 | height: 1em; 5 | border-radius: 50%; 6 | display: inline-block; 7 | vertical-align: -5%; 8 | } 9 | 10 | .legend-new .symbol 11 | { 12 | background-color: #93E929; 13 | } 14 | 15 | .legend-online .symbol 16 | { 17 | background-color: #1566A9; 18 | } 19 | 20 | .legend-offline .symbol 21 | { 22 | background-color: #D43E2A; 23 | } 24 | 25 | .legend-online, .legend-offline 26 | { 27 | margin-left: 1em; 28 | } 29 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "dataPath": "https://map.luebeck.freifunk.net/data/", 3 | "siteName": "Freifunk Lübeck", 4 | "mapSigmaScale": 0.5, 5 | "showContact": true, 6 | "maxAge": 14, 7 | "mapLayers": [ 8 | { "name": "MapQuest", 9 | "url": "https://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.jpg", 10 | "config": { 11 | "subdomains": "1234", 12 | "type": "osm", 13 | "attribution": "Tiles © MapQuest, Data CC-BY-SA OpenStreetMap", 14 | "maxZoom": 18 15 | } 16 | }, 17 | { 18 | "name": "Stamen.TonerLite" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /lib/title.js: -------------------------------------------------------------------------------- 1 | define(function () { 2 | return function (config) { 3 | function setTitle(d) { 4 | var title = [config.siteName] 5 | 6 | if (d !== undefined) 7 | title.push(d) 8 | 9 | document.title = title.join(": ") 10 | } 11 | 12 | this.resetView = function () { 13 | setTitle() 14 | } 15 | 16 | this.gotoNode = function (d) { 17 | if (d) 18 | setTitle(d.nodeinfo.hostname) 19 | } 20 | 21 | this.gotoLink = function (d) { 22 | if (d) 23 | setTitle(d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname) 24 | } 25 | 26 | this.destroy = function () { 27 | } 28 | 29 | return this 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.loadNpmTasks("grunt-git-describe") 3 | 4 | grunt.initConfig({ 5 | "git-describe": { 6 | options: {}, 7 | default: {} 8 | } 9 | }) 10 | 11 | grunt.registerTask("saveRevision", function() { 12 | grunt.event.once("git-describe", function (rev) { 13 | grunt.option("gitRevision", rev) 14 | }) 15 | grunt.task.run("git-describe") 16 | }) 17 | 18 | grunt.loadTasks("tasks") 19 | 20 | grunt.registerTask("default", ["bower-install-simple", "lint", "saveRevision", "copy", "sass", "requirejs"]) 21 | grunt.registerTask("lint", ["eslint"]) 22 | grunt.registerTask("dev", ["default", "connect:server", "watch"]) 23 | } 24 | 25 | -------------------------------------------------------------------------------- /scss/_shadow.scss: -------------------------------------------------------------------------------- 1 | /* Original is in LESS and can be found here: https://gist.github.com/gefangenimnetz/3ef3e18364edf105c5af */ 2 | 3 | @mixin shadow($level:1){ 4 | @if $level == 1 { 5 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 6 | } 7 | @else if $level == 2 { 8 | box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); 9 | } 10 | @else if $level == 3 { 11 | box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); 12 | } 13 | @else if $level == 4 { 14 | box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); 15 | } 16 | @else if $level == 5 { 17 | box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tasks/development.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.config.merge({ 3 | connect: { 4 | server: { 5 | options: { 6 | base: "build/", //TODO: once grunt-contrib-connect 0.9 is released, set index file 7 | livereload: true 8 | } 9 | } 10 | }, 11 | watch: { 12 | sources: { 13 | options: { 14 | livereload: true 15 | }, 16 | files: ["*.css", "app.js", "lib/**/*.js", "*.html"], 17 | tasks: ["default"] 18 | }, 19 | config: { 20 | options: { 21 | reload: true 22 | }, 23 | files: ["Gruntfile.js", "tasks/*.js"], 24 | tasks: [] 25 | } 26 | } 27 | }) 28 | 29 | grunt.loadNpmTasks("grunt-contrib-connect") 30 | grunt.loadNpmTasks("grunt-contrib-watch") 31 | } 32 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshviewer", 3 | "ignore": [ 4 | "node_modules", 5 | "bower_components", 6 | "**/.*", 7 | "test", 8 | "tests" 9 | ], 10 | "dependencies": { 11 | "Leaflet.label": "~0.2.1", 12 | "chroma-js": "~0.6.1", 13 | "leaflet": "~0.7.3", 14 | "ionicons": "~2.0.1", 15 | "moment": "~2.9.0", 16 | "requirejs": "~2.1.16", 17 | "tablesort": "https://github.com/tristen/tablesort.git#v3.0.2", 18 | "roboto-slab-fontface": "*", 19 | "es6-shim": "~0.27.1", 20 | "almond": "~0.3.1", 21 | "r.js": "~2.1.16", 22 | "d3": "~3.5.5", 23 | "numeraljs": "~1.5.3", 24 | "roboto-fontface": "~0.3.0", 25 | "virtual-dom": "~2.0.1", 26 | "leaflet-providers": "~1.0.27", 27 | "rbush": "https://github.com/mourner/rbush.git#~1.3.5", 28 | "jshashes": "~1.0.5" 29 | }, 30 | "authors": [ 31 | "Nils SchneiderMit Doppelklick und Shift+Doppelklick kann man in der Karte " 9 | s += "auch zoomen.
" 10 | 11 | s += "Copyright (C) Nils Schneider
" 14 | 15 | s += "This program is free software: you can redistribute it and/or " 16 | s += "modify it under the terms of the GNU Affero General Public " 17 | s += "License as published by the Free Software Foundation, either " 18 | s += "version 3 of the License, or (at your option) any later version.
" 19 | 20 | s += "This program is distributed in the hope that it will be useful, " 21 | s += "but WITHOUT ANY WARRANTY; without even the implied warranty of " 22 | s += "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the " 23 | s += "GNU Affero General Public License for more details.
" 24 | 25 | s += "You should have received a copy of the GNU Affero General " 26 | s += "Public License along with this program. If not, see " 27 | s += "" 28 | s += "https://www.gnu.org/licenses/.
" 29 | 30 | s += "You may find the source code at "
31 | s += ""
32 | s += "http://draic.info/meshviewer."
33 |
34 | el.innerHTML = s
35 | }
36 | }
37 | })
38 |
--------------------------------------------------------------------------------
/lib/legend.js:
--------------------------------------------------------------------------------
1 | define(function () {
2 | return function () {
3 | var self = this
4 |
5 | self.render = function (el) {
6 | var p = document.createElement("p")
7 | p.setAttribute("class", "legend")
8 | el.appendChild(p)
9 |
10 | var spanNew = document.createElement("span")
11 | spanNew.setAttribute("class", "legend-new")
12 | var symbolNew = document.createElement("span")
13 | symbolNew.setAttribute("class", "symbol")
14 | var textNew = document.createTextNode(" Neuer Knoten")
15 | spanNew.appendChild(symbolNew)
16 | spanNew.appendChild(textNew)
17 | p.appendChild(spanNew)
18 |
19 | var spanOnline = document.createElement("span")
20 | spanOnline.setAttribute("class", "legend-online")
21 | var symbolOnline = document.createElement("span")
22 | symbolOnline.setAttribute("class", "symbol")
23 | var textOnline = document.createTextNode(" Knoten ist online")
24 | spanOnline.appendChild(symbolOnline)
25 | spanOnline.appendChild(textOnline)
26 | p.appendChild(spanOnline)
27 |
28 | var spanOffline = document.createElement("span")
29 | spanOffline.setAttribute("class", "legend-offline")
30 | var symbolOffline = document.createElement("span")
31 | symbolOffline.setAttribute("class", "symbol")
32 | var textOffline = document.createTextNode(" Knoten ist offline")
33 | spanOffline.appendChild(symbolOffline)
34 | spanOffline.appendChild(textOffline)
35 | p.appendChild(spanOffline)
36 | }
37 |
38 | return self
39 | }
40 | })
41 |
42 |
--------------------------------------------------------------------------------
/lib/sorttable.js:
--------------------------------------------------------------------------------
1 | define(["virtual-dom"], function (V) {
2 | return function(headings, sortIndex, renderRow) {
3 | var data
4 | var sortReverse = false
5 | var el = document.createElement("table")
6 | var elLast = V.h("table")
7 |
8 | function sortTable(i) {
9 | sortReverse = i === sortIndex ? !sortReverse : false
10 | sortIndex = i
11 |
12 | updateView()
13 | }
14 |
15 | function sortTableHandler(i) {
16 | return function () { sortTable(i) }
17 | }
18 |
19 | function updateView() {
20 | var children = []
21 |
22 | if (data.length !== 0) {
23 | var th = headings.map(function (d, i) {
24 | var properties = { onclick: sortTableHandler(i),
25 | className: "sort-header"
26 | }
27 |
28 | if (sortIndex === i)
29 | properties.className += sortReverse ? " sort-up" : " sort-down"
30 |
31 | return V.h("th", properties, d.name)
32 | })
33 |
34 | var links = data.slice(0).sort(headings[sortIndex].sort)
35 |
36 | if (headings[sortIndex].reverse ? !sortReverse : sortReverse)
37 | links = links.reverse()
38 |
39 | children.push(V.h("thead", V.h("tr", th)))
40 | children.push(V.h("tbody", links.map(renderRow)))
41 | }
42 |
43 | var elNew = V.h("table", children)
44 | el = V.patch(el, V.diff(elLast, elNew))
45 | elLast = elNew
46 | }
47 |
48 | this.setData = function (d) {
49 | data = d
50 | updateView()
51 | }
52 |
53 | this.el = el
54 |
55 | return this
56 | }
57 | })
58 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## v4
4 |
5 | - add a legend (map)
6 | - new graph theme
7 | - performance improvements in graph view
8 | - various UI changes
9 | - various map fixes
10 | - moved config from config.js to config.json
11 | - online/offline statistics
12 | - define layers for map in config
13 | - graph: zoom by keyboard (+ and - keys)
14 | - direct links to graph and map views
15 |
16 | ### Bugfixes
17 |
18 | - map works with little or no nodes
19 |
20 | ## v3
21 |
22 | ### Implemented enhancements:
23 |
24 | - Make clients in map start at a random angle
25 | - On statistics page: show how many nodes supply geoinformation
26 | - Allow additional statistics (global and per node) configured in config.js
27 | - Improve node count information (total, online, clients, ...)
28 | - Show hardware model in link infobox
29 | - Introduce maxAge setting
30 | - Graph: show VPN links in grayscale
31 |
32 | ### Removed features:
33 |
34 | - Don't show contact information in node lists
35 |
36 | ### Fixed bugs:
37 |
38 | - Fixed off-by-one when drawing clients
39 | - Match labels order to node order in map
40 | - Statistics: count only nodes that are present
41 |
42 | ## v2
43 |
44 | ### General changes:
45 |
46 | - License change from GPL 3 to AGPL 3
47 |
48 | ### Implemented enhancements:
49 |
50 | - Improved performance on Firefox
51 | - Labels in graph view
52 | - infobox: link to geouri with node's coordinates
53 | - infobox: show node id
54 | - map: locate user
55 | - map: adding custom layers from leaflet.providers
56 | - nodelist: sort by uptime fixed
57 | - graph: circles for clients
58 |
59 | ### Fixed bugs:
60 |
61 | - Links disappeared on graph on refresh
62 |
--------------------------------------------------------------------------------
/lib/locationmarker.js:
--------------------------------------------------------------------------------
1 | define(["leaflet"], function (L) {
2 | return L.CircleMarker.extend({
3 | outerCircle: {
4 | stroke: false,
5 | color: "#4285F4",
6 | opacity: 1,
7 | fillOpacity: 0.3,
8 | clickable: false,
9 | radius: 16
10 | },
11 |
12 | innerCircle: {
13 | stroke: true,
14 | color: "#ffffff",
15 | fillColor: "#4285F4",
16 | weight: 1.5,
17 | clickable: false,
18 | opacity: 1,
19 | fillOpacity: 1,
20 | radius: 7
21 | },
22 |
23 | accuracyCircle: {
24 | stroke: true,
25 | color: "#4285F4",
26 | weight: 1,
27 | clickable: false,
28 | opacity: 0.7,
29 | fillOpacity: 0.2
30 | },
31 |
32 | initialize: function(latlng) {
33 | this.accuracyCircle = L.circle(latlng, 0, this.accuracyCircle)
34 | this.outerCircle = L.circleMarker(latlng, this.outerCircle)
35 | L.CircleMarker.prototype.initialize.call(this, latlng, this.innerCircle)
36 |
37 | this.on("remove", function() {
38 | this._map.removeLayer(this.accuracyCircle)
39 | this._map.removeLayer(this.outerCircle)
40 | })
41 | },
42 |
43 | setLatLng: function(latlng) {
44 | this.accuracyCircle.setLatLng(latlng)
45 | this.outerCircle.setLatLng(latlng)
46 | L.CircleMarker.prototype.setLatLng.call(this, latlng)
47 | },
48 |
49 | setAccuracy: function(accuracy) {
50 | this.accuracyCircle.setRadius(accuracy)
51 | },
52 |
53 | onAdd: function(map) {
54 | this.accuracyCircle.addTo(map).bringToBack()
55 | this.outerCircle.addTo(map)
56 | L.CircleMarker.prototype.onAdd.call(this, map)
57 | }
58 | })
59 | })
60 |
--------------------------------------------------------------------------------
/lib/meshstats.js:
--------------------------------------------------------------------------------
1 | define(function () {
2 | return function (config) {
3 | var self = this
4 | var stats, timestamp
5 |
6 | self.setData = function (d) {
7 | var totalNodes = sum(d.nodes.all.map(one))
8 | var totalOnlineNodes = sum(d.nodes.all.filter(online).map(one))
9 | var totalNewNodes = sum(d.nodes.new.map(one))
10 | var totalLostNodes = sum(d.nodes.lost.map(one))
11 | var totalClients = sum(d.nodes.all.filter(online).map( function (d) {
12 | return d.statistics.clients
13 | }))
14 |
15 | var nodetext = [{ count: totalOnlineNodes, label: "online" },
16 | { count: totalNewNodes, label: "neu" },
17 | { count: totalLostNodes, label: "verschwunden" }
18 | ].filter( function (d) { return d.count > 0 } )
19 | .map( function (d) { return [d.count, d.label].join(" ") } )
20 | .join(", ")
21 |
22 | stats.textContent = totalNodes + " Knoten " +
23 | "(" + nodetext + ") mit " +
24 | totalClients + " Client" + ( totalClients === 1 ? "" : "s" )
25 |
26 | timestamp.textContent = "Diese Daten sind von " + d.timestamp.format("LLLL") + "."
27 | }
28 |
29 | self.render = function (el) {
30 | var h2 = document.createElement("h2")
31 | h2.textContent = config.siteName
32 | el.appendChild(h2)
33 |
34 | var p = document.createElement("p")
35 | el.appendChild(p)
36 | stats = document.createTextNode("")
37 | p.appendChild(stats)
38 | p.appendChild(document.createElement("br"))
39 | timestamp = document.createTextNode("")
40 | p.appendChild(timestamp)
41 | }
42 |
43 | return self
44 | }
45 | })
46 |
--------------------------------------------------------------------------------
/lib/simplenodelist.js:
--------------------------------------------------------------------------------
1 | define(["moment", "virtual-dom"], function (moment, V) {
2 | return function(nodes, field, router, title) {
3 | var self = this
4 | var el, tbody
5 |
6 | self.render = function (d) {
7 | el = document.createElement("div")
8 | d.appendChild(el)
9 | }
10 |
11 | self.setData = function (data) {
12 | var list = data.nodes[nodes]
13 |
14 | if (list.length === 0) {
15 | while (el.firstChild)
16 | el.removeChild(el.firstChild)
17 |
18 | tbody = null
19 |
20 | return
21 | }
22 |
23 | if (!tbody) {
24 | var h2 = document.createElement("h2")
25 | h2.textContent = title
26 | el.appendChild(h2)
27 |
28 | var table = document.createElement("table")
29 | el.appendChild(table)
30 |
31 | tbody = document.createElement("tbody")
32 | tbody.last = V.h("tbody")
33 | table.appendChild(tbody)
34 | }
35 |
36 | var items = list.map( function (d) {
37 | var time = moment(d[field]).from(data.now)
38 | var td1Content = []
39 |
40 | var aClass = ["hostname", d.flags.online ? "online" : "offline"]
41 |
42 | td1Content.push(V.h("a", { className: aClass.join(" "),
43 | onclick: router.node(d),
44 | href: "#"
45 | }, d.nodeinfo.hostname))
46 |
47 | if (has_location(d))
48 | td1Content.push(V.h("span", {className: "icon ion-location"}))
49 |
50 | var td1 = V.h("td", td1Content)
51 | var td2 = V.h("td", time)
52 |
53 | return V.h("tr", [td1, td2])
54 | })
55 |
56 | var tbodyNew = V.h("tbody", items)
57 | tbody = V.patch(tbody, V.diff(tbody.last, tbodyNew))
58 | tbody.last = tbodyNew
59 | }
60 |
61 | return self
62 | }
63 | })
64 |
--------------------------------------------------------------------------------
/lib/linklist.js:
--------------------------------------------------------------------------------
1 | define(["sorttable", "virtual-dom"], function (SortTable, V) {
2 | function linkName(d) {
3 | return d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname
4 | }
5 |
6 | var headings = [{ name: "Knoten",
7 | sort: function (a, b) {
8 | return linkName(a).localeCompare(linkName(b))
9 | },
10 | reverse: false
11 | },
12 | { name: "TQ",
13 | sort: function (a, b) { return a.tq - b.tq},
14 | reverse: true
15 | },
16 | { name: "Entfernung",
17 | sort: function (a, b) {
18 | return (a.distance === undefined ? -1 : a.distance) -
19 | (b.distance === undefined ? -1 : b.distance)
20 | },
21 | reverse: true
22 | }]
23 |
24 | return function(linkScale, router) {
25 | var table = new SortTable(headings, 2, renderRow)
26 |
27 | function renderRow(d) {
28 | var td1Content = [V.h("a", {href: "#", onclick: router.link(d)}, linkName(d))]
29 |
30 | if (d.vpn)
31 | td1Content.push(" (VPN)")
32 |
33 | var td1 = V.h("td", td1Content)
34 | var td2 = V.h("td", {style: {color: linkScale(d.tq).hex()}}, showTq(d))
35 | var td3 = V.h("td", showDistance(d))
36 |
37 | return V.h("tr", [td1, td2, td3])
38 | }
39 |
40 | this.render = function (d) {
41 | var el = document.createElement("div")
42 | el.last = V.h("div")
43 | d.appendChild(el)
44 |
45 | var h2 = document.createElement("h2")
46 | h2.textContent = "Verbindungen"
47 | el.appendChild(h2)
48 |
49 | el.appendChild(table.el)
50 | }
51 |
52 | this.setData = function (d) {
53 | table.setData(d.graph.links)
54 | }
55 | }
56 | })
57 |
--------------------------------------------------------------------------------
/lib/map/clientlayer.js:
--------------------------------------------------------------------------------
1 | define(["leaflet", "jshashes"],
2 | function (L, jsHashes) {
3 | var MD5 = new jsHashes.MD5()
4 |
5 | return L.TileLayer.Canvas.extend({
6 | setData: function (d) {
7 | this.data = d
8 |
9 | //pre-calculate start angles
10 | this.data.all().forEach(function (d) {
11 | var hash = MD5.hex(d.node.nodeinfo.node_id)
12 | d.startAngle = (parseInt(hash.substr(0, 2), 16) / 255) * 2 * Math.PI
13 | })
14 | this.redraw()
15 | },
16 | drawTile: function (canvas, tilePoint) {
17 | function getTileBBox(s, map, tileSize, margin) {
18 | var tl = map.unproject([s.x - margin, s.y - margin])
19 | var br = map.unproject([s.x + margin + tileSize, s.y + margin + tileSize])
20 |
21 | return [br.lat, tl.lng, tl.lat, br.lng]
22 | }
23 |
24 | if (!this.data)
25 | return
26 |
27 | var tileSize = this.options.tileSize
28 | var s = tilePoint.multiplyBy(tileSize)
29 | var map = this._map
30 |
31 | var margin = 50
32 | var bbox = getTileBBox(s, map, tileSize, margin)
33 |
34 | var nodes = this.data.search(bbox)
35 |
36 | if (nodes.length === 0)
37 | return
38 |
39 | var ctx = canvas.getContext("2d")
40 |
41 | var radius = 3
42 | var a = 1.2
43 | var startDistance = 12
44 |
45 | ctx.beginPath()
46 | nodes.forEach(function (d) {
47 | var p = map.project([d.node.nodeinfo.location.latitude, d.node.nodeinfo.location.longitude])
48 | var clients = d.node.statistics.clients
49 |
50 | if (clients === 0)
51 | return
52 |
53 | p.x -= s.x
54 | p.y -= s.y
55 |
56 | for (var orbit = 0, i = 0; i < clients; orbit++) {
57 | var distance = startDistance + orbit * 2 * radius * a
58 | var n = Math.floor((Math.PI * distance) / (a * radius))
59 | var delta = clients - i
60 |
61 | for (var j = 0; j < Math.min(delta, n); i++, j++) {
62 | var angle = 2 * Math.PI / n * j
63 | var x = p.x + distance * Math.cos(angle + d.startAngle)
64 | var y = p.y + distance * Math.sin(angle + d.startAngle)
65 |
66 | ctx.moveTo(x, y)
67 | ctx.arc(x, y, radius, 0, 2 * Math.PI)
68 | }
69 | }
70 | })
71 |
72 | ctx.fillStyle = "rgba(220, 0, 103, 0.7)"
73 | ctx.fill()
74 | }
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/lib/nodelist.js:
--------------------------------------------------------------------------------
1 | define(["sorttable", "virtual-dom", "numeral"], function (SortTable, V, numeral) {
2 | function getUptime(now, d) {
3 | if (d.flags.online && "uptime" in d.statistics)
4 | return Math.round(d.statistics.uptime)
5 | else if (!d.flags.online && "lastseen" in d)
6 | return Math.round(-(now.unix() - d.lastseen.unix()))
7 | }
8 |
9 | function showUptime(uptime) {
10 | var s = ""
11 | uptime /= 3600
12 |
13 | if (uptime !== undefined)
14 | if (Math.abs(uptime) >= 24)
15 | s = Math.round(uptime / 24) + "d"
16 | else
17 | s = Math.round(uptime) + "h"
18 |
19 | return s
20 | }
21 |
22 | var headings = [{ name: "Knoten",
23 | sort: function (a, b) {
24 | return a.nodeinfo.hostname.localeCompare(b.nodeinfo.hostname)
25 | },
26 | reverse: false
27 | },
28 | { name: "Uptime",
29 | sort: function (a, b) {
30 | return a.uptime - b.uptime
31 | },
32 | reverse: true
33 | },
34 | { name: "Clients",
35 | sort: function (a, b) {
36 | return ("clients" in a.statistics ? a.statistics.clients : -1) -
37 | ("clients" in b.statistics ? b.statistics.clients : -1)
38 | },
39 | reverse: true
40 | }]
41 |
42 | return function(router) {
43 | function renderRow(d) {
44 | var td1Content = []
45 | var aClass = ["hostname", d.flags.online ? "online" : "offline"]
46 |
47 | td1Content.push(V.h("a", { className: aClass.join(" "),
48 | onclick: router.node(d),
49 | href: "#"
50 | }, d.nodeinfo.hostname))
51 |
52 | if (has_location(d))
53 | td1Content.push(V.h("span", {className: "icon ion-location"}))
54 |
55 | var td1 = V.h("td", td1Content)
56 | var td2 = V.h("td", showUptime(d.uptime))
57 | var td3 = V.h("td", numeral("clients" in d.statistics ? d.statistics.clients : "").format("0,0"))
58 |
59 | return V.h("tr", [td1, td2, td3])
60 | }
61 |
62 | var table = new SortTable(headings, 0, renderRow)
63 |
64 | this.render = function (d) {
65 | var el = document.createElement("div")
66 | d.appendChild(el)
67 |
68 | var h2 = document.createElement("h2")
69 | h2.textContent = "Alle Knoten"
70 | el.appendChild(h2)
71 |
72 | el.appendChild(table.el)
73 | }
74 |
75 | this.setData = function (d) {
76 | var data = d.nodes.all.map(function (e) {
77 | var n = Object.create(e)
78 | n.uptime = getUptime(d.now, e) || 0
79 | return n
80 | })
81 |
82 | table.setData(data)
83 | }
84 | }
85 | })
86 |
--------------------------------------------------------------------------------
/helper.js:
--------------------------------------------------------------------------------
1 | function get(url) {
2 | return new Promise(function(resolve, reject) {
3 | var req = new XMLHttpRequest();
4 | req.open('GET', url);
5 |
6 | req.onload = function() {
7 | if (req.status == 200) {
8 | resolve(req.response);
9 | }
10 | else {
11 | reject(Error(req.statusText));
12 | }
13 | };
14 |
15 | req.onerror = function() {
16 | reject(Error("Network Error"));
17 | };
18 |
19 | req.send();
20 | });
21 | }
22 |
23 | function getJSON(url) {
24 | return get(url).then(JSON.parse)
25 | }
26 |
27 | function sortByKey(key, d) {
28 | return d.slice().sort( function (a, b) {
29 | return a[key] - b[key]
30 | }).reverse()
31 | }
32 |
33 | function limit(key, m, d) {
34 | return d.filter( function (d) {
35 | return d[key].isAfter(m)
36 | })
37 | }
38 |
39 | function sum(a) {
40 | return a.reduce( function (a, b) {
41 | return a + b
42 | }, 0)
43 | }
44 |
45 | function one() {
46 | return 1
47 | }
48 |
49 | function trueDefault(d) {
50 | return d === undefined ? true : d
51 | }
52 |
53 | function dictGet(dict, key) {
54 | var k = key.shift()
55 |
56 | if (!(k in dict))
57 | return null
58 |
59 | if (key.length == 0)
60 | return dict[k]
61 |
62 | return dictGet(dict[k], key)
63 | }
64 |
65 | function localStorageTest() {
66 | var test = 'test'
67 | try {
68 | localStorage.setItem(test, test)
69 | localStorage.removeItem(test)
70 | return true
71 | } catch(e) {
72 | return false
73 | }
74 | }
75 |
76 | /* Helpers working with nodes */
77 |
78 | function offline(d) {
79 | return !d.flags.online
80 | }
81 |
82 | function online(d) {
83 | return d.flags.online
84 | }
85 |
86 | function has_location(d) {
87 | return "location" in d.nodeinfo &&
88 | Math.abs(d.nodeinfo.location.latitude) < 90 &&
89 | Math.abs(d.nodeinfo.location.longitude) < 180
90 | }
91 |
92 | function subtract(a, b) {
93 | var ids = {}
94 |
95 | b.forEach( function (d) {
96 | ids[d.nodeinfo.node_id] = true
97 | })
98 |
99 | return a.filter( function (d) {
100 | return !(d.nodeinfo.node_id in ids)
101 | })
102 | }
103 |
104 | /* Helpers working with links */
105 |
106 | function showDistance(d) {
107 | if (isNaN(d.distance))
108 | return
109 |
110 | return numeral(d.distance).format("0,0") + " m"
111 | }
112 |
113 | function showTq(d) {
114 | return numeral(1/d.tq).format("0%")
115 | }
116 |
117 | /* Infobox stuff (XXX: move to module) */
118 |
119 | function attributeEntry(el, label, value) {
120 | if (value === null || value == undefined)
121 | return
122 |
123 | var tr = document.createElement("tr")
124 | var th = document.createElement("th")
125 | th.textContent = label
126 | tr.appendChild(th)
127 |
128 | var td = document.createElement("td")
129 |
130 | if (typeof value == "function")
131 | value(td)
132 | else
133 | td.appendChild(document.createTextNode(value))
134 |
135 | tr.appendChild(td)
136 |
137 | el.appendChild(tr)
138 |
139 | return td
140 | }
141 |
--------------------------------------------------------------------------------
/tasks/build.js:
--------------------------------------------------------------------------------
1 | module.exports = function(grunt) {
2 | grunt.config.merge({
3 | bowerdir: "bower_components",
4 | copy: {
5 | html: {
6 | options: {
7 | process: function (content) {
8 | return content.replace("#revision#", grunt.option("gitRevision"))
9 | }
10 | },
11 | src: ["*.html"],
12 | expand: true,
13 | cwd: "html/",
14 | dest: "build/"
15 | },
16 | img: {
17 | src: ["img/*"],
18 | expand: true,
19 | dest: "build/"
20 | },
21 | vendorjs: {
22 | src: [ "es6-shim/es6-shim.min.js" ],
23 | expand: true,
24 | cwd: "bower_components/",
25 | dest: "build/vendor/"
26 | },
27 | robotoSlab: {
28 | src: [ "fonts/*",
29 | "roboto-slab-fontface.css"
30 | ],
31 | expand: true,
32 | dest: "build/",
33 | cwd: "bower_components/roboto-slab-fontface"
34 | },
35 | roboto: {
36 | src: [ "fonts/*",
37 | "roboto-fontface.css"
38 | ],
39 | expand: true,
40 | dest: "build/",
41 | cwd: "bower_components/roboto-fontface"
42 | },
43 | ionicons: {
44 | src: [ "fonts/*",
45 | "css/ionicons.min.css"
46 | ],
47 | expand: true,
48 | dest: "build/",
49 | cwd: "bower_components/ionicons/"
50 | },
51 | leafletImages: {
52 | src: [ "images/*" ],
53 | expand: true,
54 | dest: "build/",
55 | cwd: "bower_components/leaflet/dist/"
56 | }
57 | },
58 | sass: {
59 | dist: {
60 | options: {
61 | style: "compressed"
62 | },
63 | files: {
64 | "build/style.css": "scss/main.scss"
65 | }
66 | }
67 | },
68 | cssmin: {
69 | target: {
70 | files: {
71 | "build/style.css": [ "bower_components/leaflet/dist/leaflet.css",
72 | "bower_components/Leaflet.label/dist/leaflet.label.css",
73 | "style.css"
74 | ]
75 | }
76 | }
77 | },
78 | "bower-install-simple": {
79 | options: {
80 | directory: "<%=bowerdir%>",
81 | color: true,
82 | interactive: false,
83 | production: true
84 | },
85 | "prod": {
86 | options: {
87 | production: true
88 | }
89 | }
90 | },
91 | requirejs: {
92 | compile: {
93 | options: {
94 | baseUrl: "lib",
95 | name: "../bower_components/almond/almond",
96 | mainConfigFile: "app.js",
97 | include: "../app",
98 | wrap: true,
99 | optimize: "uglify",
100 | out: "build/app.js"
101 | }
102 | }
103 | }
104 | })
105 |
106 | grunt.loadNpmTasks("grunt-bower-install-simple")
107 | grunt.loadNpmTasks("grunt-contrib-copy")
108 | grunt.loadNpmTasks("grunt-contrib-requirejs")
109 | grunt.loadNpmTasks("grunt-contrib-sass")
110 | }
111 |
--------------------------------------------------------------------------------
/lib/gui.js:
--------------------------------------------------------------------------------
1 | define([ "chroma-js", "map", "sidebar", "tabs", "container", "meshstats",
2 | "legend", "linklist", "nodelist", "simplenodelist", "infobox/main",
3 | "proportions", "forcegraph", "title", "about" ],
4 | function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Legend, Linklist,
5 | Nodelist, SimpleNodelist, Infobox, Proportions, ForceGraph,
6 | Title, About) {
7 | return function (config, router) {
8 | var self = this
9 | var dataTargets = []
10 | var latestData
11 | var content
12 | var contentDiv
13 |
14 | var linkScale = chroma.scale(chroma.interpolate.bezier(["#04C714", "#FF5500", "#F02311"])).domain([1, 5])
15 | var sidebar
16 |
17 | var buttons = document.createElement("div")
18 | buttons.classList.add("buttons")
19 |
20 | function dataTargetRemove(d) {
21 | dataTargets = dataTargets.filter( function (e) { return d !== e })
22 | }
23 |
24 | function removeContent() {
25 | if (!content)
26 | return
27 |
28 | router.removeTarget(content)
29 | dataTargetRemove(content)
30 | content.destroy()
31 |
32 | content = null
33 | }
34 |
35 | function addContent(K) {
36 | removeContent()
37 |
38 | content = new K(config, linkScale, sidebar.getWidth, router, buttons)
39 | content.render(contentDiv)
40 |
41 | if (latestData)
42 | content.setData(latestData)
43 |
44 | dataTargets.push(content)
45 | router.addTarget(content)
46 | }
47 |
48 | function mkView(K) {
49 | return function () {
50 | addContent(K)
51 | }
52 | }
53 |
54 | contentDiv = document.createElement("div")
55 | contentDiv.classList.add("content")
56 | document.body.appendChild(contentDiv)
57 |
58 | sidebar = new Sidebar(document.body)
59 |
60 | contentDiv.appendChild(buttons)
61 |
62 | var buttonToggle = document.createElement("button")
63 | buttonToggle.textContent = ""
64 | buttonToggle.onclick = function () {
65 | if (content.constructor === Map)
66 | router.view("g")
67 | else
68 | router.view("m")
69 | }
70 |
71 | buttons.appendChild(buttonToggle)
72 |
73 | var title = new Title(config)
74 |
75 | var header = new Container("header")
76 | var infobox = new Infobox(config, sidebar, router)
77 | var tabs = new Tabs()
78 | var overview = new Container()
79 | var meshstats = new Meshstats(config)
80 | var legend = new Legend()
81 | var newnodeslist = new SimpleNodelist("new", "firstseen", router, "Neue Knoten")
82 | var lostnodeslist = new SimpleNodelist("lost", "lastseen", router, "Verschwundene Knoten")
83 | var nodelist = new Nodelist(router)
84 | var linklist = new Linklist(linkScale, router)
85 | var statistics = new Proportions(config)
86 | var about = new About()
87 |
88 | dataTargets.push(meshstats)
89 | dataTargets.push(newnodeslist)
90 | dataTargets.push(lostnodeslist)
91 | dataTargets.push(nodelist)
92 | dataTargets.push(linklist)
93 | dataTargets.push(statistics)
94 |
95 | sidebar.add(header)
96 | header.add(meshstats)
97 | header.add(legend)
98 |
99 | overview.add(newnodeslist)
100 | overview.add(lostnodeslist)
101 |
102 | sidebar.add(tabs)
103 | tabs.add("Aktuelles", overview)
104 | tabs.add("Knoten", nodelist)
105 | tabs.add("Verbindungen", linklist)
106 | tabs.add("Statistiken", statistics)
107 | tabs.add("Über", about)
108 |
109 | router.addTarget(title)
110 | router.addTarget(infobox)
111 |
112 | router.addView("m", mkView(Map))
113 | router.addView("g", mkView(ForceGraph))
114 |
115 | router.view("m")
116 |
117 | self.setData = function (data) {
118 | latestData = data
119 |
120 | dataTargets.forEach(function (d) {
121 | d.setData(data)
122 | })
123 | }
124 |
125 | return self
126 | }
127 | })
128 |
--------------------------------------------------------------------------------
/lib/main.js:
--------------------------------------------------------------------------------
1 | define(["moment", "router", "leaflet", "gui", "numeral"],
2 | function (moment, Router, L, GUI, numeral) {
3 | return function (config) {
4 | function handleData(data) {
5 | var dataNodes = data[0]
6 | var dataGraph = data[1]
7 |
8 | if (dataNodes.version !== 1 || dataGraph.version !== 1) {
9 | var err = "Unsupported nodes or graph version: " + dataNodes.version + " " + dataGraph.version
10 | throw err
11 | }
12 |
13 | var nodes = Object.keys(dataNodes.nodes).map(function (key) { return dataNodes.nodes[key] })
14 |
15 | nodes = nodes.filter( function (d) {
16 | return "firstseen" in d && "lastseen" in d
17 | })
18 |
19 | nodes.forEach( function(node) {
20 | node.firstseen = moment.utc(node.firstseen).local()
21 | node.lastseen = moment.utc(node.lastseen).local()
22 | })
23 |
24 | var now = moment()
25 | var age = moment(now).subtract(config.maxAge, "days")
26 |
27 | var newnodes = limit("firstseen", age, sortByKey("firstseen", nodes).filter(online))
28 | var lostnodes = limit("lastseen", age, sortByKey("lastseen", nodes).filter(offline))
29 |
30 | var graphnodes = dataNodes.nodes
31 | var graph = dataGraph.batadv
32 |
33 | graph.nodes.forEach( function (d) {
34 | if (d.node_id in graphnodes)
35 | d.node = graphnodes[d.node_id]
36 | })
37 |
38 | graph.links.forEach( function (d) {
39 | if (graph.nodes[d.source].node)
40 | d.source = graph.nodes[d.source]
41 | else
42 | d.source = undefined
43 |
44 | if (graph.nodes[d.target].node)
45 | d.target = graph.nodes[d.target]
46 | else
47 | d.target = undefined
48 | })
49 |
50 | var links = graph.links.filter( function (d) {
51 | return d.source !== undefined && d.target !== undefined
52 | })
53 |
54 | links.forEach( function (d) {
55 | var ids = [d.source.node.nodeinfo.node_id, d.target.node.nodeinfo.node_id]
56 | d.id = ids.sort().join("-")
57 |
58 | if (!("location" in d.source.node.nodeinfo && "location" in d.target.node.nodeinfo))
59 | return
60 |
61 | d.latlngs = []
62 | d.latlngs.push(L.latLng(d.source.node.nodeinfo.location.latitude, d.source.node.nodeinfo.location.longitude))
63 | d.latlngs.push(L.latLng(d.target.node.nodeinfo.location.latitude, d.target.node.nodeinfo.location.longitude))
64 |
65 | d.distance = d.latlngs[0].distanceTo(d.latlngs[1])
66 | })
67 |
68 | nodes.forEach( function (d) {
69 | d.neighbours = []
70 | })
71 |
72 | links.forEach( function (d) {
73 | d.source.node.neighbours.push({ node: d.target.node, link: d })
74 | d.target.node.neighbours.push({ node: d.source.node, link: d })
75 | })
76 |
77 | return { now: now,
78 | timestamp: moment.utc(data[0].timestamp).local(),
79 | nodes: {
80 | all: nodes,
81 | new: newnodes,
82 | lost: lostnodes
83 | },
84 | graph: {
85 | links: links,
86 | nodes: graph.nodes
87 | }
88 | }
89 | }
90 |
91 | numeral.language("de")
92 | moment.locale("de")
93 |
94 | var router = new Router()
95 |
96 | var urls = [ config.dataPath + "nodes.json",
97 | config.dataPath + "graph.json"
98 | ]
99 | function update() {
100 | return Promise.all(urls.map(getJSON))
101 | .then(handleData)
102 | }
103 |
104 | update()
105 | .then(function (d) {
106 | var gui = new GUI(config, router)
107 | gui.setData(d)
108 | router.setData(d)
109 | router.start()
110 |
111 | window.setInterval(function () {
112 | update().then(function (d) {
113 | gui.setData(d)
114 | router.setData(d)
115 | })
116 | }, 60000)
117 | })
118 | .catch(function (e) {
119 | document.body.textContent = e
120 | console.log(e)
121 | })
122 | }
123 | })
124 |
--------------------------------------------------------------------------------
/lib/router.js:
--------------------------------------------------------------------------------
1 | define(function () {
2 | return function () {
3 | var self = this
4 | var objects = { nodes: {}, links: {} }
5 | var targets = []
6 | var views = {}
7 | var currentView
8 | var currentObject
9 | var running = false
10 |
11 | function saveState() {
12 | var e = []
13 |
14 | if (currentView)
15 | e.push("v:" + currentView)
16 |
17 | if (currentObject) {
18 | if ("node" in currentObject)
19 | e.push("n:" + encodeURIComponent(currentObject.node.nodeinfo.node_id))
20 |
21 | if ("link" in currentObject)
22 | e.push("l:" + encodeURIComponent(currentObject.link.id))
23 | }
24 |
25 | var s = "#!" + e.join(";")
26 |
27 | window.history.pushState(s, undefined, s)
28 | }
29 |
30 | function resetView(push) {
31 | push = trueDefault(push)
32 |
33 | targets.forEach( function (t) {
34 | t.resetView()
35 | })
36 |
37 | if (push) {
38 | currentObject = undefined
39 | saveState()
40 | }
41 | }
42 |
43 | function gotoNode(d) {
44 | if (!d)
45 | return false
46 |
47 | targets.forEach( function (t) {
48 | t.gotoNode(d)
49 | })
50 |
51 | return true
52 | }
53 |
54 | function gotoLink(d) {
55 | if (!d)
56 | return false
57 |
58 | targets.forEach( function (t) {
59 | t.gotoLink(d)
60 | })
61 |
62 | return true
63 | }
64 |
65 | function loadState(s) {
66 | if (!s)
67 | return false
68 |
69 | if (!s.startsWith("#!"))
70 | return false
71 |
72 | var targetSet = false
73 |
74 | s.slice(2).split(";").forEach(function (d) {
75 | var args = d.split(":")
76 |
77 | if (args[0] === "v" && args[1] in views) {
78 | currentView = args[1]
79 | views[args[1]]()
80 | }
81 |
82 | var id
83 |
84 | if (args[0] === "n") {
85 | id = decodeURIComponent(args[1])
86 | if (id in objects.nodes) {
87 | currentObject = { node: objects.nodes[id] }
88 | gotoNode(objects.nodes[id])
89 | targetSet = true
90 | }
91 | }
92 |
93 | if (args[0] === "l") {
94 | id = decodeURIComponent(args[1])
95 | if (id in objects.links) {
96 | currentObject = { link: objects.links[id] }
97 | gotoLink(objects.links[id])
98 | targetSet = true
99 | }
100 | }
101 | })
102 |
103 | return targetSet
104 | }
105 |
106 | self.start = function () {
107 | running = true
108 |
109 | if (!loadState(window.location.hash))
110 | resetView(false)
111 |
112 | window.onpopstate = function (d) {
113 | if (!loadState(d.state))
114 | resetView(false)
115 | }
116 | }
117 |
118 | self.view = function (d) {
119 | if (d in views) {
120 | views[d]()
121 |
122 | if (!currentView || running)
123 | currentView = d
124 |
125 | if (!running)
126 | return
127 |
128 | saveState()
129 |
130 | if (!currentObject) {
131 | resetView(false)
132 | return
133 | }
134 |
135 | if ("node" in currentObject)
136 | gotoNode(currentObject.node)
137 |
138 | if ("link" in currentObject)
139 | gotoLink(currentObject.link)
140 | }
141 | }
142 |
143 | self.node = function (d) {
144 | return function () {
145 | if (gotoNode(d)) {
146 | currentObject = { node: d }
147 | saveState()
148 | }
149 |
150 | return false
151 | }
152 | }
153 |
154 | self.link = function (d) {
155 | return function () {
156 | if (gotoLink(d)) {
157 | currentObject = { link: d }
158 | saveState()
159 | }
160 |
161 | return false
162 | }
163 | }
164 |
165 | self.reset = function () {
166 | resetView()
167 | }
168 |
169 | self.addTarget = function (d) {
170 | targets.push(d)
171 | }
172 |
173 | self.removeTarget = function (d) {
174 | targets = targets.filter( function (e) {
175 | return d !== e
176 | })
177 | }
178 |
179 | self.addView = function (k, d) {
180 | views[k] = d
181 | }
182 |
183 | self.setData = function (data) {
184 | objects.nodes = {}
185 | objects.links = {}
186 |
187 | data.nodes.all.forEach( function (d) {
188 | objects.nodes[d.nodeinfo.node_id] = d
189 | })
190 |
191 | data.graph.links.forEach( function (d) {
192 | objects.links[d.id] = d
193 | })
194 | }
195 |
196 | return self
197 | }
198 | })
199 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This Fork is not maintained!
2 |
3 | This was the original branch of the Meshviewer, but it is not further developed here. There are two main branches, that are worked on.
4 |
5 | Please use https://github.com/ffrgb/meshviewer
6 |
7 | If you are a developer, please consider joining this follow-up project or pickup on the also inactive
8 | project at https://github.com/hopglass/hopglass
9 |
10 | ---
11 |
12 | [](https://travis-ci.org/ffnord/meshviewer)
13 |
14 | # Meshviewer
15 |
16 | Meshviewer is a frontend for
17 | [ffmap-backend](https://github.com/ffnord/ffmap-backend).
18 |
19 |
20 | [Changelog](CHANGELOG.md)
21 |
22 | # Screenshots
23 |
24 | 
25 | 
26 | 
27 | 
28 | 
29 |
30 | # Dependencies
31 |
32 | - npm
33 | - bower
34 | - grunt-cli
35 | - Sass (>= 3.2)
36 |
37 | # Installing dependencies
38 |
39 | Install npm and Sass with your package-manager. On Debian-like systems run:
40 |
41 | sudo apt-get install npm ruby-sass
42 |
43 | or if you have bundler you can install ruby-sass simply via `bundle install`
44 |
45 | Execute these commands on your server as a normal user to prepare the dependencies:
46 |
47 | git clone https://github.com/tcatm/meshviewer.git
48 | cd meshviewer
49 | npm install
50 | npm install grunt-cli
51 |
52 | # Building
53 |
54 | Just run the following command from the meshviewer directory:
55 |
56 | node_modules/.bin/grunt
57 |
58 | This will generate `build/` containing all required files.
59 |
60 | # Configure
61 |
62 | Copy `config.json.example` to `build/config.json` and change it to match your community.
63 |
64 | ## dataPath (string)
65 |
66 | `dataPath` must point to a directory containing `nodes.json` and `graph.json`
67 | (both are generated by
68 | [ffmap-backend](https://github.com/ffnord/ffmap-backend)). Don't forget the
69 | trailing slash! Data may be served from a different domain with [CORS enabled].
70 | Also, GZip will greatly reduce bandwidth consumption.
71 |
72 | ## siteName (string)
73 |
74 | Change this to match your communities' name. It will be used in various places.
75 |
76 | ## mapSigmaScale (float)
77 |
78 | This affects the initial scale of the map. Greater values will show a larger
79 | area. Values like 1.0 and 0.5 might be good choices.
80 |
81 | ## showContact (bool)
82 |
83 | Setting this to `false` will hide contact information for nodes.
84 |
85 | ## maxAge (integer)
86 |
87 | Nodes being online for less than maxAge days are considered "new". Likewise,
88 | nodes being offline for less than than maxAge days are considered "lost".
89 |
90 | ## mapLayers (List)
91 |
92 | A list of objects describing map layers. Each object has at least `name`
93 | property and optionally `url` and `config` properties. If no `url` is supplied
94 | `name` is assumed to name a
95 | [Leaflet-provider](http://leaflet-extras.github.io/leaflet-providers/preview/).
96 |
97 | ## nodeInfos (array, optional)
98 |
99 | This option allows to show client statistics depending on following case-sensitive parameters:
100 |
101 | - `name` caption of statistics segment in infobox
102 | - `href` absolute or relative URL to statistics image
103 | - `thumbnail` absolute or relative URL to thumbnail image,
104 | can be the same like `href`
105 | - `caption` is shown, if `thumbnail` is not present (no thumbnail in infobox)
106 |
107 | To insert current node-id in either `href`, `thumbnail` or `caption`
108 | you can use the case-sensitive template string `{NODE_ID}`.
109 |
110 | Examples for `nodeInfos`:
111 |
112 | "nodeInfos": [
113 | { "name": "Clientstatistik",
114 | "href": "nodes/{NODE_ID}.png",
115 | "thumbnail": "nodes/{NODE_ID}.png",
116 | "caption": "Knoten {NODE_ID}"
117 | },
118 | { "name": "Uptime",
119 | "href": "nodes_uptime/{NODE_ID}.png",
120 | "thumbnail": "nodes_uptime/{NODE_ID}.png",
121 | "caption": "Knoten {NODE_ID}"
122 | }
123 | ]
124 |
125 | In order to have statistics images available, you have to run the backend with parameter `--with-rrd` or generate them in other ways.
126 |
127 | ## globalInfos (array, optional)
128 |
129 | This option allows to show global statistics on statistics page depending on following case-sensitive parameters:
130 |
131 | - `name` caption of statistics segment in infobox
132 | - `href` absolute or relative URL to statistics image
133 | - `thumbnail` absolute or relative URL to thumbnail image,
134 | can be the same like `href`
135 | - `caption` is shown, if `thumbnail` is not present (no thumbnail in infobox)
136 |
137 | In contrast to `nodeInfos` there is no template substitution in `href`, `thumbnail` or `caption`.
138 |
139 | Examples for `globalInfos`:
140 |
141 | "globalInfos": [
142 | { "name": "Wochenstatistik",
143 | "href": "nodes/globalGraph.png",
144 | "thumbnail": "nodes/globalGraph.png",
145 | "caption": "Bild mit Wochenstatistik"
146 | },
147 | { "name": "Jahresstatistik",
148 | "href": "nodes/globalGraph52.png",
149 | "thumbnail": "nodes/globalGraph52.png",
150 | "caption": "Bild mit Jahresstatistik"
151 | }
152 | ]
153 |
154 | 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.
155 |
156 | [CORS enabled]: http://enable-cors.org/server.html
157 |
--------------------------------------------------------------------------------
/lib/proportions.js:
--------------------------------------------------------------------------------
1 | define(["chroma-js", "virtual-dom", "numeral-intl"],
2 | function (Chroma, V, numeral) {
3 |
4 | return function (config) {
5 | var self = this
6 | var scale = Chroma.scale("YlGnBu").mode("lab")
7 |
8 | var statusTable = document.createElement("table")
9 | statusTable.classList.add("proportion")
10 |
11 | var fwTable = document.createElement("table")
12 | fwTable.classList.add("proportion")
13 |
14 | var hwTable = document.createElement("table")
15 | hwTable.classList.add("proportion")
16 |
17 | var geoTable = document.createElement("table")
18 | geoTable.classList.add("proportion")
19 |
20 | var autoTable = document.createElement("table")
21 | autoTable.classList.add("proportion")
22 |
23 | function showStatGlobal(o) {
24 | var content, caption
25 |
26 | if (o.thumbnail) {
27 | content = document.createElement("img")
28 | content.src = o.thumbnail
29 | }
30 |
31 | if (o.caption) {
32 | caption = o.caption
33 |
34 | if (!content)
35 | content = document.createTextNode(caption)
36 | }
37 |
38 | var p = document.createElement("p")
39 |
40 | if (o.href) {
41 | var link = document.createElement("a")
42 | link.target = "_blank"
43 | link.href = o.href
44 | link.appendChild(content)
45 |
46 | if (caption && o.thumbnail)
47 | link.title = caption
48 |
49 | p.appendChild(link)
50 | } else
51 | p.appendChild(content)
52 |
53 | return p
54 | }
55 |
56 | function count(nodes, key, f) {
57 | var dict = {}
58 |
59 | nodes.forEach( function (d) {
60 | var v = dictGet(d, key.slice(0))
61 |
62 | if (f !== undefined)
63 | v = f(v)
64 |
65 | if (v === null)
66 | return
67 |
68 | dict[v] = 1 + (v in dict ? dict[v] : 0)
69 | })
70 |
71 | return Object.keys(dict).map(function (d) { return [d, dict[d]] })
72 | }
73 |
74 | function fillTable(table, data) {
75 | if (!table.last)
76 | table.last = V.h("table")
77 |
78 | var max = 0
79 | data.forEach(function (d) {
80 | if (d[1] > max)
81 | max = d[1]
82 | })
83 |
84 | var items = data.map(function (d) {
85 | var v = d[1] / max
86 | var c1 = Chroma.contrast(scale(v), "white")
87 | var c2 = Chroma.contrast(scale(v), "black")
88 |
89 | var th = V.h("th", d[0])
90 | var td = V.h("td", V.h("span", {style: {
91 | width: Math.round(v * 100) + "%",
92 | backgroundColor: scale(v).hex(),
93 | color: c1 > c2 ? "white" : "black"
94 | }}, numeral(d[1]).format("0,0")))
95 |
96 | return V.h("tr", [th, td])
97 | })
98 |
99 | var tableNew = V.h("table", items)
100 | table = V.patch(table, V.diff(table.last, tableNew))
101 | table.last = tableNew
102 | }
103 |
104 | self.setData = function (data) {
105 | var onlineNodes = data.nodes.all.filter(online)
106 | var nodes = onlineNodes.concat(data.nodes.lost)
107 | var nodeDict = {}
108 |
109 | data.nodes.all.forEach(function (d) {
110 | nodeDict[d.nodeinfo.node_id] = d
111 | })
112 |
113 | var statusDict = count(nodes, ["flags", "online"], function (d) {
114 | return d ? "online" : "offline"
115 | })
116 | var fwDict = count(nodes, ["nodeinfo", "software", "firmware", "release"])
117 | var hwDict = count(nodes, ["nodeinfo", "hardware", "model"])
118 | var geoDict = count(nodes, ["nodeinfo", "location"], function (d) {
119 | return d ? "ja" : "nein"
120 | })
121 | var autoDict = count(nodes, ["nodeinfo", "software", "autoupdater"], function (d) {
122 | if (d === null)
123 | return null
124 | else if (d.enabled)
125 | return d.branch
126 | else
127 | return "(deaktiviert)"
128 | })
129 |
130 | fillTable(statusTable, statusDict.sort(function (a, b) { return b[1] - a[1] }))
131 | fillTable(fwTable, fwDict.sort(function (a, b) { return b[1] - a[1] }))
132 | fillTable(hwTable, hwDict.sort(function (a, b) { return b[1] - a[1] }))
133 | fillTable(geoTable, geoDict.sort(function (a, b) { return b[1] - a[1] }))
134 | fillTable(autoTable, autoDict.sort(function (a, b) { return b[1] - a[1] }))
135 | }
136 |
137 | self.render = function (el) {
138 | var h2
139 | h2 = document.createElement("h2")
140 | h2.textContent = "Status"
141 | el.appendChild(h2)
142 | el.appendChild(statusTable)
143 |
144 | h2 = document.createElement("h2")
145 | h2.textContent = "Firmwareversionen"
146 | el.appendChild(h2)
147 | el.appendChild(fwTable)
148 |
149 | h2 = document.createElement("h2")
150 | h2.textContent = "Hardwaremodelle"
151 | el.appendChild(h2)
152 | el.appendChild(hwTable)
153 |
154 | h2 = document.createElement("h2")
155 | h2.textContent = "Auf der Karte sichtbar"
156 | el.appendChild(h2)
157 | el.appendChild(geoTable)
158 |
159 | h2 = document.createElement("h2")
160 | h2.textContent = "Autoupdater"
161 | el.appendChild(h2)
162 | el.appendChild(autoTable)
163 |
164 | if (config.globalInfos)
165 | config.globalInfos.forEach( function (globalInfo) {
166 | h2 = document.createElement("h2")
167 | h2.textContent = globalInfo.name
168 | el.appendChild(h2)
169 |
170 | el.appendChild(showStatGlobal(globalInfo))
171 | })
172 | }
173 |
174 | return self
175 | }
176 | })
177 |
--------------------------------------------------------------------------------
/lib/map/labelslayer.js:
--------------------------------------------------------------------------------
1 | define(["leaflet", "rbush"],
2 | function (L, rbush) {
3 | var labelLocations = [["left", "middle", 0 / 8],
4 | ["center", "top", 6 / 8],
5 | ["right", "middle", 4 / 8],
6 | ["left", "top", 7 / 8],
7 | ["left", "ideographic", 1 / 8],
8 | ["right", "top", 5 / 8],
9 | ["center", "ideographic", 2 / 8],
10 | ["right", "ideographic", 3 / 8]]
11 |
12 | var fontFamily = "Roboto"
13 | var nodeRadius = 4
14 |
15 | var ctx = document.createElement("canvas").getContext("2d")
16 |
17 | function measureText(font, text) {
18 | ctx.font = font
19 | return ctx.measureText(text)
20 | }
21 |
22 | function mapRTree(d) {
23 | var o = [d.position.lat, d.position.lng, d.position.lat, d.position.lng]
24 |
25 | o.label = d
26 |
27 | return o
28 | }
29 |
30 | function prepareLabel(fillStyle, fontSize, offset, stroke, minZoom) {
31 | return function (d) {
32 | var font = fontSize + "px " + fontFamily
33 | return { position: L.latLng(d.nodeinfo.location.latitude, d.nodeinfo.location.longitude),
34 | label: d.nodeinfo.hostname,
35 | offset: offset,
36 | fillStyle: fillStyle,
37 | height: fontSize * 1.2,
38 | font: font,
39 | stroke: stroke,
40 | minZoom: minZoom,
41 | width: measureText(font, d.nodeinfo.hostname).width
42 | }
43 | }
44 | }
45 |
46 | function calcOffset(offset, loc) {
47 | return [ offset * Math.cos(loc[2] * 2 * Math.PI),
48 | -offset * Math.sin(loc[2] * 2 * Math.PI)]
49 | }
50 |
51 | function labelRect(p, offset, anchor, label, minZoom, maxZoom, z) {
52 | var margin = 1 + 1.41 * (1 - (z - minZoom) / (maxZoom - minZoom))
53 |
54 | var width = label.width * margin
55 | var height = label.height * margin
56 |
57 | var dx = { left: 0,
58 | right: -width,
59 | center: -width / 2
60 | }
61 |
62 | var dy = { top: 0,
63 | ideographic: -height,
64 | middle: -height / 2
65 | }
66 |
67 | var x = p.x + offset[0] + dx[anchor[0]]
68 | var y = p.y + offset[1] + dy[anchor[1]]
69 |
70 | return [x, y, x + width, y + height]
71 | }
72 |
73 | var c = L.TileLayer.Canvas.extend({
74 | onAdd: function (map) {
75 | L.TileLayer.Canvas.prototype.onAdd.call(this, map)
76 | if (this.data)
77 | this.prepareLabels()
78 | },
79 | setData: function (d) {
80 | this.data = d
81 | if (this._map)
82 | this.prepareLabels()
83 | },
84 | prepareLabels: function () {
85 | var d = this.data
86 |
87 | // label:
88 | // - position (WGS84 coords)
89 | // - offset (2D vector in pixels)
90 | // - anchor (tuple, textAlignment, textBaseline)
91 | // - minZoom (inclusive)
92 | // - label (string)
93 | // - color (string)
94 |
95 | var labelsOnline = d.online.map(prepareLabel("rgba(0, 0, 0, 0.9)", 10, 8, true, 13))
96 | var labelsOffline = d.offline.map(prepareLabel("rgba(212, 62, 42, 0.9)", 9, 5, false, 16))
97 | var labelsNew = d.new.map(prepareLabel("rgba(48, 99, 20, 0.9)", 11, 8, true, 0))
98 | var labelsLost = d.lost.map(prepareLabel("rgba(212, 62, 42, 0.9)", 11, 8, true, 0))
99 |
100 | var labels = []
101 | .concat(labelsNew)
102 | .concat(labelsLost)
103 | .concat(labelsOnline)
104 | .concat(labelsOffline)
105 |
106 | var minZoom = this.options.minZoom
107 | var maxZoom = this.options.maxZoom
108 |
109 | var trees = []
110 |
111 | var map = this._map
112 |
113 | function nodeToRect(z) {
114 | return function (d) {
115 | var p = map.project(d.position, z)
116 | return [p.x - nodeRadius, p.y - nodeRadius,
117 | p.x + nodeRadius, p.y + nodeRadius]
118 | }
119 | }
120 |
121 | for (var z = minZoom; z <= maxZoom; z++) {
122 | trees[z] = rbush(9)
123 | trees[z].load(labels.map(nodeToRect(z)))
124 | }
125 |
126 | labels = labels.map(function (d) {
127 | var best = labelLocations.map(function (loc) {
128 | var offset = calcOffset(d.offset, loc)
129 | var z
130 |
131 | for (z = maxZoom; z >= d.minZoom; z--) {
132 | var p = map.project(d.position, z)
133 | var rect = labelRect(p, offset, loc, d, minZoom, maxZoom, z)
134 | var candidates = trees[z].search(rect)
135 |
136 | if (candidates.length > 0)
137 | break
138 | }
139 |
140 | return {loc: loc, z: z + 1}
141 | }).filter(function (d) {
142 | return d.z <= maxZoom
143 | }).sort(function (a, b) {
144 | return a.z - b.z
145 | })[0]
146 |
147 | if (best !== undefined) {
148 | d.offset = calcOffset(d.offset, best.loc)
149 | d.minZoom = best.z
150 | d.anchor = best.loc
151 |
152 | for (var z = maxZoom; z >= best.z; z--) {
153 | var p = map.project(d.position, z)
154 | var rect = labelRect(p, d.offset, best.loc, d, minZoom, maxZoom, z)
155 | trees[z].insert(rect)
156 | }
157 |
158 | return d
159 | } else
160 | return undefined
161 | }).filter(function (d) { return d !== undefined })
162 |
163 | this.margin = 16
164 |
165 | if (labels.length > 0)
166 | this.margin += labels.map(function (d) {
167 | return d.width
168 | }).sort().reverse()[0]
169 |
170 | this.labels = rbush(9)
171 | this.labels.load(labels.map(mapRTree))
172 |
173 | this.redraw()
174 | },
175 | drawTile: function (canvas, tilePoint, zoom) {
176 | function getTileBBox(s, map, tileSize, margin) {
177 | var tl = map.unproject([s.x - margin, s.y - margin])
178 | var br = map.unproject([s.x + margin + tileSize, s.y + margin + tileSize])
179 |
180 | return [br.lat, tl.lng, tl.lat, br.lng]
181 | }
182 |
183 | if (!this.labels)
184 | return
185 |
186 | var tileSize = this.options.tileSize
187 | var s = tilePoint.multiplyBy(tileSize)
188 | var map = this._map
189 |
190 | function projectNodes(d) {
191 | var p = map.project(d.label.position)
192 |
193 | p.x -= s.x
194 | p.y -= s.y
195 |
196 | return {p: p, label: d.label}
197 | }
198 |
199 | var bbox = getTileBBox(s, map, tileSize, this.margin)
200 |
201 | var labels = this.labels.search(bbox).map(projectNodes)
202 |
203 | var ctx = canvas.getContext("2d")
204 |
205 | ctx.lineWidth = 5
206 | ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"
207 | ctx.miterLimit = 2
208 |
209 | function drawLabel(d) {
210 | ctx.font = d.label.font
211 | ctx.textAlign = d.label.anchor[0]
212 | ctx.textBaseline = d.label.anchor[1]
213 | ctx.fillStyle = d.label.fillStyle
214 |
215 | if (d.label.stroke)
216 | ctx.strokeText(d.label.label, d.p.x + d.label.offset[0], d.p.y + d.label.offset[1])
217 |
218 | ctx.fillText(d.label.label, d.p.x + d.label.offset[0], d.p.y + d.label.offset[1])
219 | }
220 |
221 | labels.filter(function (d) {
222 | return zoom >= d.label.minZoom
223 | }).forEach(drawLabel)
224 | }
225 | })
226 |
227 | return c
228 | })
229 |
--------------------------------------------------------------------------------
/scss/main.scss:
--------------------------------------------------------------------------------
1 | @import '_reset';
2 | @import '_shadow';
3 | @import '_base';
4 | @import '_leaflet';
5 | @import '_leaflet.label';
6 |
7 | $minscreenwidth: 630pt;
8 | $sidebarwidth: 420pt;
9 | $sidebarwidthsmall: 320pt;
10 | $buttondistance: 12pt;
11 |
12 | @import '_sidebar';
13 | @import '_map';
14 | @import '_forcegraph';
15 | @import '_legend';
16 |
17 | .content {
18 | position: fixed;
19 | width: 100%;
20 | height: 100vh;
21 |
22 | .buttons {
23 | direction: rtl;
24 | unicode-bidi: bidi-override;
25 |
26 | z-index: 100;
27 | position: absolute;
28 | top: $buttondistance;
29 | right: $buttondistance;
30 |
31 | button {
32 | margin-left: $buttondistance;
33 | }
34 | }
35 | }
36 |
37 | .tabs, header {
38 | background: rgba(0, 0, 0, 0.02);
39 | }
40 |
41 | .tabs {
42 | padding: 1em 0 0 !important;
43 | margin: 0;
44 | list-style: none;
45 | display: flex;
46 | font-family: Roboto;
47 | @include shadow(1);
48 | }
49 |
50 | .tabs li {
51 | flex: 1 1 auto;
52 | text-transform: uppercase;
53 | text-align: center;
54 | padding: 0.5em 0.5em 1em;
55 | cursor: pointer;
56 | color: rgba(0, 0, 0, 0.5);
57 | }
58 |
59 | .tabs li:hover {
60 | color: #dc0067;
61 | }
62 |
63 | .tabs .visible {
64 | border-bottom: 2pt solid #dc0067;
65 | color: #dc0067;
66 | }
67 |
68 | body {
69 | margin: 0;
70 | padding: 0;
71 | font-family: 'Roboto Slab', serif;
72 | font-size: 11pt;
73 | }
74 |
75 | th.sort-header::selection {
76 | background: transparent;
77 | }
78 |
79 | th.sort-header {
80 | cursor: pointer;
81 | }
82 |
83 | table th.sort-header:after {
84 | font-family: "ionicons";
85 | padding-left: 0.25em;
86 | content: '\f10d';
87 | visibility: hidden;
88 | }
89 |
90 | table th.sort-header:hover:after {
91 | visibility: visible;
92 | }
93 |
94 | table th.sort-up:after, table th.sort-down:after, table th.sort-down:hover:after {
95 | visibility: visible;
96 | opacity: 0.4;
97 | }
98 |
99 | table th.sort-up:after {
100 | content: '\f104';
101 | }
102 |
103 | table.attributes th {
104 | text-align: left;
105 | font-weight: bold;
106 | vertical-align: top;
107 | padding-right: 1em;
108 | white-space: nowrap;
109 | line-height: 1.41em;
110 | }
111 |
112 | table.attributes td {
113 | text-align: left !important;
114 | width: 100%;
115 | line-height: 1.41em;
116 | }
117 |
118 | .sidebar {
119 | .infobox, .container {
120 | @include shadow(2);
121 | background: rgba(255, 255, 255, 0.97);
122 | border-radius: 2px;
123 | }
124 |
125 | .container.hidden {
126 | display: none;
127 | }
128 |
129 | p {
130 | line-height: 1.67em;
131 | }
132 | }
133 |
134 | .infobox .clients {
135 | font-family: "ionicons";
136 | color: #1566A9;
137 | word-spacing: -0.2em;
138 | white-space: normal;
139 | }
140 |
141 | .infobox {
142 | position: relative;
143 | padding: 0.25em 0;
144 | margin-bottom: $buttondistance;
145 |
146 | img {
147 | max-width: 100%;
148 | }
149 | }
150 |
151 | button {
152 | -webkit-tap-highlight-color: transparent;
153 | font-family: "ionicons";
154 | @include shadow(1);
155 | border-radius: 0.9em;
156 | background: rgba(255, 255, 255, 0.7);
157 | border: none;
158 | cursor: pointer;
159 | height: 1.8em;
160 | width: 1.8em;
161 | font-size: 20pt;
162 | transition: box-shadow 0.5s, color 0.5s;
163 | outline: none;
164 | }
165 |
166 | button.active {
167 | color: #dc0067 !important;
168 | }
169 |
170 | button:hover {
171 | background: white;
172 | color: #dc0067;
173 | @include shadow(2);
174 | }
175 |
176 | button:active {
177 | box-shadow: inset 0px 5px 20px rgba(0, 0, 0, 0.19), inset 0px 3px 6px rgba(0, 0, 0, 0.23);
178 | }
179 |
180 | button::-moz-focus-inner {
181 | border: 0;
182 | }
183 |
184 | button.close {
185 | width: auto;
186 | height: auto;
187 | font-size: 20pt;
188 | float: right;
189 | margin-right: $buttondistance;
190 | margin-top: $buttondistance;
191 | box-shadow: none;
192 | background: transparent;
193 | border-radius: 0;
194 | color: rgba(0, 0, 0, 0.5);
195 | font-family: "ionicons";
196 |
197 | &:hover {
198 | color: #dc0067;
199 | }
200 |
201 | &:after {
202 | content: '\f2d7';
203 | }
204 | }
205 |
206 | .sidebar h2, .sidebar h3 {
207 | padding-left: $buttondistance;
208 | padding-right: $buttondistance;
209 | }
210 |
211 | .sidebar {
212 | p, pre, ul, h4 {
213 | padding: 0 $buttondistance 1em;
214 | }
215 |
216 | table {
217 | padding: 0 $buttondistance;
218 | }
219 | img {
220 | max-width: 100%;
221 | }
222 | }
223 |
224 | table {
225 | border-spacing: 0 0.5em;
226 | td, th {
227 | line-height: 1.41em;
228 | }
229 | }
230 |
231 | .sidebar table {
232 | border-collapse: separate;
233 | }
234 |
235 | .sidebar table th {
236 | font-weight: bold;
237 | }
238 |
239 | .sidebarhandle {
240 | position: fixed;
241 | left: $sidebarwidth + 2 * $buttondistance;
242 | top: $buttondistance;
243 | z-index: 10;
244 | transition: left 0.5s, box-shadow 0.5s, color 0.5s, transform 0.5s;
245 | }
246 |
247 | .sidebarhandle:after {
248 | padding-right: 0.125em;
249 | content: "\f124";
250 | }
251 |
252 | .sidebar.hidden .sidebarhandle {
253 | transform: scale(-1, 1);
254 | left: $buttondistance;
255 | }
256 |
257 | .online {
258 | color: #558020 !important;
259 | }
260 |
261 | .offline {
262 | color: #D43E2A !important;
263 | }
264 |
265 | .sidebar {
266 | z-index: 5;
267 | width: $sidebarwidth;
268 | box-sizing: border-box;
269 | position: absolute;
270 | top: $buttondistance;
271 | left: $buttondistance;
272 | margin-bottom: $buttondistance;
273 | transition: left 0.5s;
274 | }
275 |
276 | .sidebar.hidden {
277 | left: -$sidebarwidth - $buttondistance;
278 | }
279 |
280 | .sidebar .icon {
281 | padding: 0 0.25em;
282 | }
283 |
284 | .sidebar table {
285 | width: 100%;
286 | }
287 |
288 | .sidebar table th {
289 | text-align: left;
290 | }
291 |
292 | .sidebar td:not(:first-child), .sidebar th:not(:first-child) {
293 | text-align: right;
294 | }
295 |
296 | .sidebar a {
297 | color: #1566A9;
298 | }
299 |
300 | .bar {
301 | display: block;
302 | height: 1.4em;
303 | background: rgba(85, 128, 32, 0.5);
304 | position: relative;
305 |
306 | span {
307 | display: inline-block;
308 | height: 1.4em;
309 | background: rgba(85, 128, 32, 0.8);
310 | }
311 |
312 | label {
313 | font-weight: bold;
314 | white-space: nowrap;
315 | color: white;
316 | position: absolute;
317 | right: 0.5em;
318 | top: 0.1em;
319 | }
320 | }
321 |
322 | .proportion th {
323 | font-weight: normal !important;
324 | text-align: right !important;
325 | font-size: 0.95em;
326 | padding-right: 0.71em;
327 | }
328 |
329 | .proportion td {
330 | text-align: left !important;
331 | width: 100%;
332 | }
333 |
334 | .proportion td, .proportion th {
335 | white-space: nowrap;
336 | }
337 |
338 | .proportion span {
339 | display: inline-block;
340 | background: black;
341 | padding: 0.25em 0.5em;
342 | font-weight: bold;
343 | min-width: 1.5em;
344 | box-sizing: border-box;
345 | }
346 |
347 | @media screen and (max-width: 80em) {
348 | .sidebar {
349 | font-size: 0.8em;
350 | top: 0pt;
351 | left: 0pt;
352 | margin: 0pt;
353 | width: $sidebarwidthsmall;
354 | min-height: 100vh;
355 | @include shadow(2);
356 | background: white;
357 |
358 | .sidebarhandle {
359 | left: $sidebarwidthsmall + $buttondistance;
360 | }
361 |
362 | .container, .infobox {
363 | margin: 0;
364 | box-shadow: none;
365 | border-radius: 0;
366 | }
367 | }
368 | }
369 |
370 | @media screen and (max-width: $minscreenwidth) {
371 | .sidebarhandle {
372 | display: none;
373 | }
374 |
375 | .content {
376 | position: relative;
377 | width: auto;
378 | height: 60vh;
379 | }
380 |
381 | .sidebar {
382 | position: static;
383 | margin: 0em !important;
384 | width: auto;
385 | height: auto;
386 | min-height: 0;
387 | }
388 |
389 | .sidebar.hidden {
390 | width: auto;
391 | }
392 | }
393 |
--------------------------------------------------------------------------------
/lib/infobox/node.js:
--------------------------------------------------------------------------------
1 | define(["moment", "numeral", "tablesort", "tablesort.numeric"],
2 | function (moment, numeral, Tablesort) {
3 | function showGeoURI(d) {
4 | function showLatitude(d) {
5 | var suffix = Math.sign(d) > -1 ? "' N" : "' S"
6 | d = Math.abs(d)
7 | var a = Math.floor(d)
8 | var min = (d * 60) % 60
9 | a = (a < 10 ? "0" : "") + a
10 |
11 | return a + "° " + numeral(min).format("0.000") + suffix
12 | }
13 |
14 | function showLongitude(d) {
15 | var suffix = Math.sign(d) > -1 ? "' E" : "' W"
16 | d = Math.abs(d)
17 | var a = Math.floor(d)
18 | var min = (d * 60) % 60
19 | a = (a < 100 ? "0" + (a < 10 ? "0" : "") : "") + a
20 |
21 | return a + "° " + numeral(min).format("0.000") + suffix
22 | }
23 |
24 | if (!has_location(d))
25 | return undefined
26 |
27 | return function (el) {
28 | var latitude = d.nodeinfo.location.latitude
29 | var longitude = d.nodeinfo.location.longitude
30 | var a = document.createElement("a")
31 | a.textContent = showLatitude(latitude) + " " +
32 | showLongitude(longitude)
33 |
34 | a.href = "geo:" + latitude + "," + longitude
35 | el.appendChild(a)
36 | }
37 | }
38 |
39 | function showStatus(d) {
40 | return function (el) {
41 | el.classList.add(d.flags.online ? "online" : "offline")
42 | el.textContent = d.flags.online ? "online" : "offline, " + d.lastseen.fromNow(true)
43 | }
44 | }
45 |
46 | function showFirmware(d) {
47 | var release = dictGet(d.nodeinfo, ["software", "firmware", "release"])
48 | var base = dictGet(d.nodeinfo, ["software", "firmware", "base"])
49 |
50 | if (release === null || base === null)
51 | return undefined
52 |
53 | return release + " / " + base
54 | }
55 |
56 | function showUptime(d) {
57 | if (!("uptime" in d.statistics))
58 | return undefined
59 |
60 | return moment.duration(d.statistics.uptime, "seconds").humanize()
61 | }
62 |
63 | function showFirstseen(d) {
64 | if (!("firstseen" in d))
65 | return undefined
66 |
67 | return d.firstseen.fromNow(true)
68 | }
69 |
70 | function showClients(d) {
71 | if (!d.flags.online)
72 | return undefined
73 |
74 | return function (el) {
75 | el.appendChild(document.createTextNode(d.statistics.clients > 0 ? d.statistics.clients : "keine"))
76 | el.appendChild(document.createElement("br"))
77 |
78 | var span = document.createElement("span")
79 | span.classList.add("clients")
80 | span.textContent = " ".repeat(d.statistics.clients)
81 | el.appendChild(span)
82 | }
83 | }
84 |
85 | function showIPs(d) {
86 | var ips = dictGet(d.nodeinfo, ["network", "addresses"])
87 | if (ips === null)
88 | return undefined
89 |
90 | ips.sort()
91 |
92 | return function (el) {
93 | ips.forEach( function (ip, i) {
94 | var link = !ip.startsWith("fe80:")
95 |
96 | if (i > 0)
97 | el.appendChild(document.createElement("br"))
98 |
99 | if (link) {
100 | var a = document.createElement("a")
101 | a.href = "http://[" + ip + "]/"
102 | a.textContent = ip
103 | el.appendChild(a)
104 | } else
105 | el.appendChild(document.createTextNode(ip))
106 | })
107 | }
108 | }
109 |
110 | function showBar(className, v) {
111 | var span = document.createElement("span")
112 | span.classList.add("bar")
113 | span.classList.add(className)
114 |
115 | var bar = document.createElement("span")
116 | bar.style.width = (v * 100) + "%"
117 | span.appendChild(bar)
118 |
119 | var label = document.createElement("label")
120 | label.textContent = (Math.round(v * 100)) + " %"
121 | span.appendChild(label)
122 |
123 | return span
124 | }
125 |
126 | function showRAM(d) {
127 | if (!("memory_usage" in d.statistics))
128 | return undefined
129 |
130 | return function (el) {
131 | el.appendChild(showBar("memory-usage", d.statistics.memory_usage))
132 | }
133 | }
134 |
135 | function showAutoupdate(d) {
136 | var au = dictGet(d.nodeinfo, ["software", "autoupdater"])
137 | if (!au)
138 | return undefined
139 |
140 | return au.enabled ? "aktiviert (" + au.branch + ")" : "deaktiviert"
141 | }
142 |
143 | function showStatImg(o, nodeId) {
144 | var content, caption
145 |
146 | if (o.thumbnail) {
147 | content = document.createElement("img")
148 | content.src = o.thumbnail.replace("{NODE_ID}", nodeId)
149 | }
150 |
151 | if (o.caption) {
152 | caption = o.caption.replace("{NODE_ID}", nodeId)
153 |
154 | if (!content)
155 | content = document.createTextNode(caption)
156 | }
157 |
158 | var p = document.createElement("p")
159 |
160 | if (o.href) {
161 | var link = document.createElement("a")
162 | link.target = "_blank"
163 | link.href = o.href.replace("{NODE_ID}", nodeId)
164 | link.appendChild(content)
165 |
166 | if (caption && o.thumbnail)
167 | link.title = caption
168 |
169 | p.appendChild(link)
170 | } else
171 | p.appendChild(content)
172 |
173 | return p
174 | }
175 |
176 | return function(config, el, router, d) {
177 | var h2 = document.createElement("h2")
178 | h2.textContent = d.nodeinfo.hostname
179 | el.appendChild(h2)
180 |
181 | var attributes = document.createElement("table")
182 | attributes.classList.add("attributes")
183 |
184 | attributeEntry(attributes, "Status", showStatus(d))
185 | attributeEntry(attributes, "Koordinaten", showGeoURI(d))
186 |
187 | if (config.showContact)
188 | attributeEntry(attributes, "Kontakt", dictGet(d.nodeinfo, ["owner", "contact"]))
189 |
190 | attributeEntry(attributes, "Hardware", dictGet(d.nodeinfo, ["hardware", "model"]))
191 | attributeEntry(attributes, "Primäre MAC", dictGet(d.nodeinfo, ["network", "mac"]))
192 | attributeEntry(attributes, "Node ID", dictGet(d.nodeinfo, ["node_id"]))
193 | attributeEntry(attributes, "Firmware", showFirmware(d))
194 | attributeEntry(attributes, "Uptime", showUptime(d))
195 | attributeEntry(attributes, "Teil des Netzes", showFirstseen(d))
196 | attributeEntry(attributes, "Arbeitsspeicher", showRAM(d))
197 | attributeEntry(attributes, "IP Adressen", showIPs(d))
198 | attributeEntry(attributes, "Autom. Updates", showAutoupdate(d))
199 | attributeEntry(attributes, "Clients", showClients(d))
200 |
201 | el.appendChild(attributes)
202 |
203 |
204 | if (config.nodeInfos)
205 | config.nodeInfos.forEach( function (nodeInfo) {
206 | var h4 = document.createElement("h4")
207 | h4.textContent = nodeInfo.name
208 | el.appendChild(h4)
209 | el.appendChild(showStatImg(nodeInfo, d.nodeinfo.node_id))
210 | })
211 |
212 | if (d.neighbours.length > 0) {
213 | var h3 = document.createElement("h3")
214 | h3.textContent = "Nachbarknoten (" + d.neighbours.length + ")"
215 | el.appendChild(h3)
216 |
217 | var table = document.createElement("table")
218 | var thead = document.createElement("thead")
219 |
220 | var tr = document.createElement("tr")
221 | var th1 = document.createElement("th")
222 | th1.textContent = "Knoten"
223 | th1.classList.add("sort-default")
224 | tr.appendChild(th1)
225 |
226 | var th2 = document.createElement("th")
227 | th2.textContent = "TQ"
228 | tr.appendChild(th2)
229 |
230 | var th3 = document.createElement("th")
231 | th3.textContent = "Entfernung"
232 | tr.appendChild(th3)
233 |
234 | thead.appendChild(tr)
235 | table.appendChild(thead)
236 |
237 | var tbody = document.createElement("tbody")
238 |
239 | d.neighbours.forEach( function (d) {
240 | var tr = document.createElement("tr")
241 |
242 | var td1 = document.createElement("td")
243 | var a1 = document.createElement("a")
244 | a1.classList.add("hostname")
245 | a1.textContent = d.node.nodeinfo.hostname
246 | a1.href = "#"
247 | a1.onclick = router.node(d.node)
248 | td1.appendChild(a1)
249 |
250 | if (d.link.vpn)
251 | td1.appendChild(document.createTextNode(" (VPN)"))
252 |
253 | if (has_location(d.node)) {
254 | var span = document.createElement("span")
255 | span.classList.add("icon")
256 | span.classList.add("ion-location")
257 | td1.appendChild(span)
258 | }
259 |
260 | tr.appendChild(td1)
261 |
262 | var td2 = document.createElement("td")
263 | var a2 = document.createElement("a")
264 | a2.href = "#"
265 | a2.textContent = showTq(d.link)
266 | a2.onclick = router.link(d.link)
267 | td2.appendChild(a2)
268 | tr.appendChild(td2)
269 |
270 | var td3 = document.createElement("td")
271 | var a3 = document.createElement("a")
272 | a3.href = "#"
273 | a3.textContent = showDistance(d.link)
274 | a3.onclick = router.link(d.link)
275 | td3.appendChild(a3)
276 | td3.setAttribute("data-sort", d.link.distance !== undefined ? -d.link.distance : 1)
277 | tr.appendChild(td3)
278 |
279 | tbody.appendChild(tr)
280 | })
281 |
282 | table.appendChild(tbody)
283 |
284 | new Tablesort(table)
285 |
286 | el.appendChild(table)
287 | }
288 | }
289 | })
290 |
--------------------------------------------------------------------------------
/lib/map.js:
--------------------------------------------------------------------------------
1 | define(["map/clientlayer", "map/labelslayer",
2 | "d3", "leaflet", "moment", "locationmarker", "rbush",
3 | "leaflet.label", "leaflet.providers"],
4 | function (ClientLayer, LabelsLayer, d3, L, moment, LocationMarker, rbush) {
5 | var options = { worldCopyJump: true,
6 | zoomControl: false
7 | }
8 |
9 | var AddLayerButton = L.Control.extend({
10 | options: {
11 | position: "bottomright"
12 | },
13 |
14 | initialize: function (f, options) {
15 | L.Util.setOptions(this, options)
16 | this.f = f
17 | },
18 |
19 | onAdd: function () {
20 | var button = L.DomUtil.create("button", "add-layer")
21 | button.textContent = ""
22 |
23 | L.DomEvent.disableClickPropagation(button)
24 | L.DomEvent.addListener(button, "click", this.f, this)
25 |
26 | this.button = button
27 |
28 | return button
29 | }
30 | })
31 |
32 | var LocateButton = L.Control.extend({
33 | options: {
34 | position: "bottomright"
35 | },
36 |
37 | active: false,
38 | button: undefined,
39 |
40 | initialize: function (f, options) {
41 | L.Util.setOptions(this, options)
42 | this.f = f
43 | },
44 |
45 | onAdd: function () {
46 | var button = L.DomUtil.create("button", "locate-user")
47 | button.textContent = ""
48 |
49 | L.DomEvent.disableClickPropagation(button)
50 | L.DomEvent.addListener(button, "click", this.onClick, this)
51 |
52 | this.button = button
53 |
54 | return button
55 | },
56 |
57 | update: function() {
58 | this.button.classList.toggle("active", this.active)
59 | },
60 |
61 | set: function(v) {
62 | this.active = v
63 | this.update()
64 | },
65 |
66 | onClick: function () {
67 | this.f(!this.active)
68 | }
69 | })
70 |
71 | function mkMarker(dict, iconFunc, router) {
72 | return function (d) {
73 | var m = L.circleMarker([d.nodeinfo.location.latitude, d.nodeinfo.location.longitude], iconFunc(d))
74 |
75 | m.resetStyle = function () {
76 | m.setStyle(iconFunc(d))
77 | }
78 |
79 | m.on("click", router.node(d))
80 | m.bindLabel(d.nodeinfo.hostname)
81 |
82 | dict[d.nodeinfo.node_id] = m
83 |
84 | return m
85 | }
86 | }
87 |
88 | function addLinksToMap(dict, linkScale, graph, router) {
89 | graph = graph.filter( function (d) {
90 | return "distance" in d && !d.vpn
91 | })
92 |
93 | var lines = graph.map( function (d) {
94 | var opts = { color: linkScale(d.tq).hex(),
95 | weight: 4,
96 | opacity: 0.5,
97 | dashArray: "none"
98 | }
99 |
100 | var line = L.polyline(d.latlngs, opts)
101 |
102 | line.resetStyle = function () {
103 | line.setStyle(opts)
104 | }
105 |
106 | line.bindLabel(d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname + "
" + showDistance(d) + " / " + showTq(d) + "")
107 | line.on("click", router.link(d))
108 |
109 | dict[d.id] = line
110 |
111 | return line
112 | })
113 |
114 | return lines
115 | }
116 |
117 | var iconOnline = { color: "#1566A9", fillColor: "#1566A9", radius: 6, fillOpacity: 0.5, opacity: 0.5, weight: 2, className: "stroke-first" }
118 | var iconOffline = { color: "#D43E2A", fillColor: "#D43E2A", radius: 3, fillOpacity: 0.5, opacity: 0.5, weight: 1, className: "stroke-first" }
119 | var iconLost = { color: "#D43E2A", fillColor: "#D43E2A", radius: 6, fillOpacity: 0.8, opacity: 0.8, weight: 1, className: "stroke-first" }
120 | var iconAlert = { color: "#D43E2A", fillColor: "#D43E2A", radius: 6, fillOpacity: 0.8, opacity: 0.8, weight: 2, className: "stroke-first node-alert" }
121 | var iconNew = { color: "#1566A9", fillColor: "#93E929", radius: 6, fillOpacity: 1.0, opacity: 0.5, weight: 2 }
122 |
123 | return function (config, linkScale, sidebar, router, buttons) {
124 | var self = this
125 | var barycenter
126 | var groupOnline, groupOffline, groupNew, groupLost, groupLines
127 | var savedView
128 |
129 | var map, userLocation
130 | var layerControl
131 | var customLayers = new Set()
132 | var baseLayers = {}
133 |
134 | var locateUserButton = new LocateButton(function (d) {
135 | if (d)
136 | enableTracking()
137 | else
138 | disableTracking()
139 | })
140 |
141 | var mybuttons = []
142 |
143 | function addButton(button) {
144 | var el = button.onAdd()
145 | mybuttons.push(el)
146 | buttons.appendChild(el)
147 | }
148 |
149 | function clearButtons() {
150 | mybuttons.forEach( function (d) {
151 | buttons.removeChild(d)
152 | })
153 | }
154 |
155 | function saveView() {
156 | savedView = {center: map.getCenter(),
157 | zoom: map.getZoom()}
158 | }
159 |
160 | function enableTracking() {
161 | map.locate({watch: true,
162 | enableHighAccuracy: true,
163 | setView: true
164 | })
165 | locateUserButton.set(true)
166 | }
167 |
168 | function disableTracking() {
169 | map.stopLocate()
170 | locationError()
171 | locateUserButton.set(false)
172 | }
173 |
174 | function locationFound(e) {
175 | if (!userLocation)
176 | userLocation = new LocationMarker(e.latlng).addTo(map)
177 |
178 | userLocation.setLatLng(e.latlng)
179 | userLocation.setAccuracy(e.accuracy)
180 | }
181 |
182 | function locationError() {
183 | if (userLocation) {
184 | map.removeLayer(userLocation)
185 | userLocation = null
186 | }
187 | }
188 |
189 | function addLayer(layerName) {
190 | if (layerName in baseLayers)
191 | return
192 |
193 | if (customLayers.has(layerName))
194 | return
195 |
196 | try {
197 | var layer = L.tileLayer.provider(layerName)
198 | layerControl.addBaseLayer(layer, layerName)
199 | customLayers.add(layerName)
200 |
201 | if (localStorageTest())
202 | localStorage.setItem("map/customLayers", JSON.stringify(Array.from(customLayers)))
203 | } catch (e) {
204 | return
205 | }
206 | }
207 |
208 | var el = document.createElement("div")
209 | el.classList.add("map")
210 |
211 | map = L.map(el, options)
212 |
213 | var layers = config.mapLayers.map( function (d) {
214 | return {
215 | "name": d.name,
216 | "layer": "url" in d ? L.tileLayer(d.url, d.config) : L.tileLayer.provider(d.name)
217 | }
218 | })
219 |
220 | layers[0].layer.addTo(map)
221 |
222 | layers.forEach( function (d) {
223 | baseLayers[d.name] = d.layer
224 | })
225 |
226 | map.on("locationfound", locationFound)
227 | map.on("locationerror", locationError)
228 | map.on("dragend", saveView)
229 |
230 | addButton(locateUserButton)
231 |
232 | addButton(new AddLayerButton(function () {
233 | /*eslint no-alert:0*/
234 | var layerName = prompt("Leaflet Provider:")
235 | addLayer(layerName)
236 | }))
237 |
238 | layerControl = L.control.layers(baseLayers, [], {position: "bottomright"})
239 | layerControl.addTo(map)
240 |
241 | if (localStorageTest()) {
242 | var d = JSON.parse(localStorage.getItem("map/customLayers"))
243 |
244 | if (d)
245 | d.forEach(addLayer)
246 | }
247 |
248 | var clientLayer = new ClientLayer({minZoom: 15})
249 | clientLayer.addTo(map)
250 | clientLayer.setZIndex(5)
251 |
252 | var labelsLayer = new LabelsLayer()
253 | labelsLayer.addTo(map)
254 | labelsLayer.setZIndex(6)
255 |
256 | var nodeDict = {}
257 | var linkDict = {}
258 | var highlight
259 |
260 | function resetMarkerStyles(nodes, links) {
261 | Object.keys(nodes).forEach( function (d) {
262 | nodes[d].resetStyle()
263 | })
264 |
265 | Object.keys(links).forEach( function (d) {
266 | links[d].resetStyle()
267 | })
268 | }
269 |
270 | function setView(bounds) {
271 | map.fitBounds(bounds, {paddingTopLeft: [sidebar(), 0]})
272 | }
273 |
274 | function resetZoom() {
275 | if (barycenter)
276 | setView(barycenter.getBounds())
277 | }
278 |
279 | function goto(m) {
280 | var bounds
281 |
282 | if ("getBounds" in m)
283 | bounds = m.getBounds()
284 | else
285 | bounds = L.latLngBounds([m.getLatLng()])
286 |
287 | setView(bounds)
288 |
289 | return m
290 | }
291 |
292 | function updateView(nopanzoom) {
293 | resetMarkerStyles(nodeDict, linkDict)
294 | var m
295 |
296 | if (highlight !== undefined)
297 | if (highlight.type === "node") {
298 | m = nodeDict[highlight.o.nodeinfo.node_id]
299 |
300 | if (m)
301 | m.setStyle({ color: "orange", weight: 20, fillOpacity: 1, opacity: 0.7, className: "stroke-first" })
302 | } else if (highlight.type === "link") {
303 | m = linkDict[highlight.o.id]
304 |
305 | if (m)
306 | m.setStyle({ weight: 7, opacity: 1, dashArray: "10, 10" })
307 | }
308 |
309 | if (!nopanzoom)
310 | if (m)
311 | goto(m)
312 | else if (savedView)
313 | map.setView(savedView.center, savedView.zoom)
314 | else
315 | resetZoom()
316 | }
317 |
318 | function calcBarycenter(nodes) {
319 | nodes = nodes.map(function (d) { return d.nodeinfo.location })
320 |
321 | if (nodes.length === 0)
322 | return undefined
323 |
324 | var lats = nodes.map(function (d) { return d.latitude })
325 | var lngs = nodes.map(function (d) { return d.longitude })
326 |
327 | var barycenter = L.latLng(d3.median(lats), d3.median(lngs))
328 | var barycenterDev = [d3.deviation(lats), d3.deviation(lngs)]
329 |
330 | if (barycenterDev[0] === undefined)
331 | barycenterDev[0] = 0
332 |
333 | if (barycenterDev[1] === undefined)
334 | barycenterDev[1] = 0
335 |
336 | var barycenterCircle = L.latLng(barycenter.lat + barycenterDev[0],
337 | barycenter.lng + barycenterDev[1])
338 |
339 | var r = barycenter.distanceTo(barycenterCircle)
340 |
341 | return L.circle(barycenter, r * config.mapSigmaScale)
342 | }
343 |
344 | function mapRTree(d) {
345 | var o = [ d.nodeinfo.location.latitude, d.nodeinfo.location.longitude,
346 | d.nodeinfo.location.latitude, d.nodeinfo.location.longitude]
347 |
348 | o.node = d
349 |
350 | return o
351 | }
352 |
353 | self.setData = function (data) {
354 | nodeDict = {}
355 | linkDict = {}
356 |
357 | if (groupOffline)
358 | groupOffline.clearLayers()
359 |
360 | if (groupOnline)
361 | groupOnline.clearLayers()
362 |
363 | if (groupNew)
364 | groupNew.clearLayers()
365 |
366 | if (groupLost)
367 | groupLost.clearLayers()
368 |
369 | if (groupLines)
370 | groupLines.clearLayers()
371 |
372 | var lines = addLinksToMap(linkDict, linkScale, data.graph.links, router)
373 | groupLines = L.featureGroup(lines).addTo(map)
374 |
375 | barycenter = calcBarycenter(data.nodes.all.filter(has_location))
376 |
377 | var nodesOnline = subtract(data.nodes.all.filter(online), data.nodes.new)
378 | var nodesOffline = subtract(data.nodes.all.filter(offline), data.nodes.lost)
379 |
380 | var markersOnline = nodesOnline.filter(has_location)
381 | .map(mkMarker(nodeDict, function () { return iconOnline }, router))
382 |
383 | var markersOffline = nodesOffline.filter(has_location)
384 | .map(mkMarker(nodeDict, function () { return iconOffline }, router))
385 |
386 | var markersNew = data.nodes.new.filter(has_location)
387 | .map(mkMarker(nodeDict, function () { return iconNew }, router))
388 |
389 | var markersLost = data.nodes.lost.filter(has_location)
390 | .map(mkMarker(nodeDict, function (d) {
391 | if (d.lastseen.isAfter(moment(data.now).subtract(3, "days")))
392 | return iconAlert
393 |
394 | return iconLost
395 | }, router))
396 |
397 | groupOffline = L.featureGroup(markersOffline).addTo(map)
398 | groupOnline = L.featureGroup(markersOnline).addTo(map)
399 | groupLost = L.featureGroup(markersLost).addTo(map)
400 | groupNew = L.featureGroup(markersNew).addTo(map)
401 |
402 | var rtreeOnlineAll = rbush(9)
403 |
404 | rtreeOnlineAll.load(data.nodes.all.filter(online).filter(has_location).map(mapRTree))
405 |
406 | clientLayer.setData(rtreeOnlineAll)
407 | labelsLayer.setData({online: nodesOnline.filter(has_location),
408 | offline: nodesOffline.filter(has_location),
409 | new: data.nodes.new.filter(has_location),
410 | lost: data.nodes.lost.filter(has_location)
411 | })
412 |
413 | updateView(true)
414 | }
415 |
416 | self.resetView = function () {
417 | disableTracking()
418 | highlight = undefined
419 | updateView()
420 | }
421 |
422 | self.gotoNode = function (d) {
423 | disableTracking()
424 | highlight = {type: "node", o: d}
425 | updateView()
426 | }
427 |
428 | self.gotoLink = function (d) {
429 | disableTracking()
430 | highlight = {type: "link", o: d}
431 | updateView()
432 | }
433 |
434 | self.destroy = function () {
435 | clearButtons()
436 | map.remove()
437 |
438 | if (el.parentNode)
439 | el.parentNode.removeChild(el)
440 | }
441 |
442 | self.render = function (d) {
443 | d.appendChild(el)
444 | map.invalidateSize()
445 | }
446 |
447 | return self
448 | }
449 | })
450 |
--------------------------------------------------------------------------------
/lib/forcegraph.js:
--------------------------------------------------------------------------------
1 | define(["d3"], function (d3) {
2 | var margin = 200
3 | var NODE_RADIUS = 15
4 | var LINE_RADIUS = 12
5 |
6 | return function (config, linkScale, sidebar, router) {
7 | var self = this
8 | var canvas, ctx, screenRect
9 | var nodesDict, linksDict
10 | var zoomBehavior
11 | var force
12 | var el
13 | var doAnimation = false
14 | var intNodes = []
15 | var intLinks = []
16 | var highlight
17 | var highlightedNodes = []
18 | var highlightedLinks = []
19 | var nodes = []
20 | var unknownNodes = []
21 | var savedPanZoom
22 |
23 | var draggedNode
24 |
25 | var LINK_DISTANCE = 70
26 |
27 | function graphDiameter(nodes) {
28 | return Math.sqrt(nodes.length / Math.PI) * LINK_DISTANCE * 1.41
29 | }
30 |
31 | function savePositions() {
32 | if (!localStorageTest())
33 | return
34 |
35 | var save = intNodes.map( function (d) {
36 | return { id: d.o.id, x: d.x, y: d.y }
37 | })
38 |
39 | localStorage.setItem("graph/nodeposition", JSON.stringify(save))
40 | }
41 |
42 | function nodeName(d) {
43 | if (d.o.node && d.o.node.nodeinfo)
44 | return d.o.node.nodeinfo.hostname
45 | else
46 | return d.o.id
47 | }
48 |
49 | function dragstart() {
50 | var e = translateXY(d3.mouse(el))
51 |
52 | var nodes = intNodes.filter(function (d) {
53 | return distancePoint(e, d) < NODE_RADIUS
54 | })
55 |
56 | if (nodes.length === 0)
57 | return
58 |
59 | draggedNode = nodes[0]
60 | d3.event.sourceEvent.stopPropagation()
61 | d3.event.sourceEvent.preventDefault()
62 | draggedNode.fixed |= 2
63 |
64 | draggedNode.px = draggedNode.x
65 | draggedNode.py = draggedNode.y
66 | }
67 |
68 | function dragmove() {
69 | if (draggedNode) {
70 | var e = translateXY(d3.mouse(el))
71 |
72 | draggedNode.px = e.x
73 | draggedNode.py = e.y
74 | force.resume()
75 | }
76 | }
77 |
78 | function dragend() {
79 | if (draggedNode) {
80 | d3.event.sourceEvent.stopPropagation()
81 | d3.event.sourceEvent.preventDefault()
82 | draggedNode.fixed &= ~2
83 | draggedNode = undefined
84 | }
85 | }
86 |
87 | var draggableNode = d3.behavior.drag()
88 | .on("dragstart", dragstart)
89 | .on("drag", dragmove)
90 | .on("dragend", dragend)
91 |
92 | function animatePanzoom(translate, scale) {
93 | var translateP = zoomBehavior.translate()
94 | var scaleP = zoomBehavior.scale()
95 |
96 | if (!doAnimation) {
97 | zoomBehavior.translate(translate)
98 | zoomBehavior.scale(scale)
99 | panzoom()
100 | } else {
101 | var start = {x: translateP[0], y: translateP[1], scale: scaleP}
102 | var end = {x: translate[0], y: translate[1], scale: scale}
103 |
104 | var interpolate = d3.interpolateObject(start, end)
105 | var duration = 500
106 |
107 | var ease = d3.ease("cubic-in-out")
108 |
109 | d3.timer(function (t) {
110 | if (t >= duration)
111 | return true
112 |
113 | var v = interpolate(ease(t / duration))
114 | zoomBehavior.translate([v.x, v.y])
115 | zoomBehavior.scale(v.scale)
116 | panzoom()
117 |
118 | return false
119 | })
120 | }
121 | }
122 |
123 | function onPanZoom() {
124 | savedPanZoom = {translate: zoomBehavior.translate(),
125 | scale: zoomBehavior.scale()}
126 | panzoom()
127 | }
128 |
129 | function panzoom() {
130 | var translate = zoomBehavior.translate()
131 | var scale = zoomBehavior.scale()
132 |
133 |
134 | panzoomReal(translate, scale)
135 | }
136 |
137 | function panzoomReal(translate, scale) {
138 | screenRect = {left: -translate[0] / scale, top: -translate[1] / scale,
139 | right: (canvas.width - translate[0]) / scale,
140 | bottom: (canvas.height - translate[1]) / scale}
141 |
142 | requestAnimationFrame(redraw)
143 | }
144 |
145 | function getSize() {
146 | var sidebarWidth = sidebar()
147 | var width = el.offsetWidth - sidebarWidth
148 | var height = el.offsetHeight
149 |
150 | return [width, height]
151 | }
152 |
153 | function panzoomTo(a, b) {
154 | var sidebarWidth = sidebar()
155 | var size = getSize()
156 |
157 | var targetWidth = Math.max(1, b[0] - a[0])
158 | var targetHeight = Math.max(1, b[1] - a[1])
159 |
160 | var scaleX = size[0] / targetWidth
161 | var scaleY = size[1] / targetHeight
162 | var scaleMax = zoomBehavior.scaleExtent()[1]
163 | var scale = 0.5 * Math.min(scaleMax, Math.min(scaleX, scaleY))
164 |
165 | var centroid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
166 | var x = -centroid[0] * scale + size[0] / 2
167 | var y = -centroid[1] * scale + size[1] / 2
168 | var translate = [x + sidebarWidth, y]
169 |
170 | animatePanzoom(translate, scale)
171 | }
172 |
173 | function updateHighlight(nopanzoom) {
174 | highlightedNodes = []
175 | highlightedLinks = []
176 |
177 | if (highlight !== undefined)
178 | if (highlight.type === "node") {
179 | var n = nodesDict[highlight.o.nodeinfo.node_id]
180 |
181 | if (n) {
182 | highlightedNodes = [n]
183 |
184 | if (!nopanzoom)
185 | panzoomTo([n.x, n.y], [n.x, n.y])
186 | }
187 |
188 | return
189 | } else if (highlight.type === "link") {
190 | var l = linksDict[highlight.o.id]
191 |
192 | if (l) {
193 | highlightedLinks = [l]
194 |
195 | if (!nopanzoom) {
196 | var x = d3.extent([l.source, l.target], function (d) { return d.x })
197 | var y = d3.extent([l.source, l.target], function (d) { return d.y })
198 | panzoomTo([x[0], y[0]], [x[1], y[1]])
199 | }
200 | }
201 |
202 | return
203 | }
204 |
205 | if (!nopanzoom)
206 | if (!savedPanZoom)
207 | panzoomTo([0, 0], force.size())
208 | else
209 | animatePanzoom(savedPanZoom.translate, savedPanZoom.scale)
210 | }
211 |
212 | function drawLabel(d) {
213 | var neighbours = d.neighbours.filter(function (d) {
214 | return !d.link.o.vpn
215 | })
216 |
217 | var sum = neighbours.reduce(function (a, b) {
218 | return [a[0] + b.node.x, a[1] + b.node.y]
219 | }, [0, 0])
220 |
221 | var sumCos = sum[0] - d.x * neighbours.length
222 | var sumSin = sum[1] - d.y * neighbours.length
223 |
224 | var angle = Math.PI / 2
225 |
226 | if (neighbours.length > 0)
227 | angle = Math.PI + Math.atan2(sumSin, sumCos)
228 |
229 | var cos = Math.cos(angle)
230 | var sin = Math.sin(angle)
231 |
232 | var width = d.labelWidth
233 | var height = d.labelHeight
234 |
235 | var x = d.x + d.labelA * Math.pow(Math.abs(cos), 2 / 5) * Math.sign(cos) - width / 2
236 | var y = d.y + d.labelB * Math.pow(Math.abs(sin), 2 / 5) * Math.sign(sin) - height / 2
237 |
238 | ctx.drawImage(d.label, x, y, width, height)
239 | }
240 |
241 | function visibleLinks(d) {
242 | return (d.source.x > screenRect.left && d.source.x < screenRect.right &&
243 | d.source.y > screenRect.top && d.source.y < screenRect.bottom) ||
244 | (d.target.x > screenRect.left && d.target.x < screenRect.right &&
245 | d.target.y > screenRect.top && d.target.y < screenRect.bottom) ||
246 | d.o.vpn
247 | }
248 |
249 | function visibleNodes(d) {
250 | return d.x + margin > screenRect.left && d.x - margin < screenRect.right &&
251 | d.y + margin > screenRect.top && d.y - margin < screenRect.bottom
252 | }
253 |
254 | function redraw() {
255 | var r = window.devicePixelRatio
256 | var translate = zoomBehavior.translate()
257 | var scale = zoomBehavior.scale()
258 | var links = intLinks.filter(visibleLinks)
259 |
260 | ctx.save()
261 | ctx.setTransform(1, 0, 0, 1, 0, 0)
262 | ctx.clearRect(0, 0, canvas.width, canvas.height)
263 | ctx.restore()
264 |
265 | ctx.save()
266 | ctx.translate(translate[0], translate[1])
267 | ctx.scale(scale, scale)
268 |
269 | var clientColor = "rgba(230, 50, 75, 1.0)"
270 | var unknownColor = "#D10E2A"
271 | var nodeColor = "#F2E3C6"
272 | var highlightColor = "rgba(252, 227, 198, 0.15)"
273 | var nodeRadius = 6
274 |
275 | // -- draw links --
276 | ctx.save()
277 | links.forEach(function (d) {
278 | var dx = d.target.x - d.source.x
279 | var dy = d.target.y - d.source.y
280 | var a = Math.sqrt(dx * dx + dy * dy)
281 | dx /= a
282 | dy /= a
283 |
284 | ctx.beginPath()
285 | ctx.moveTo(d.source.x + dx * nodeRadius, d.source.y + dy * nodeRadius)
286 | ctx.lineTo(d.target.x - dx * nodeRadius, d.target.y - dy * nodeRadius)
287 | ctx.strokeStyle = d.color
288 | ctx.globalAlpha = d.o.vpn ? 0.1 : 0.8
289 | ctx.lineWidth = d.o.vpn ? 1.5 : 2.5
290 | ctx.stroke()
291 | })
292 |
293 | ctx.restore()
294 |
295 | // -- draw unknown nodes --
296 | ctx.beginPath()
297 | unknownNodes.filter(visibleNodes).forEach(function (d) {
298 | ctx.moveTo(d.x + nodeRadius, d.y)
299 | ctx.arc(d.x, d.y, nodeRadius, 0, 2 * Math.PI)
300 | })
301 |
302 | ctx.strokeStyle = unknownColor
303 | ctx.lineWidth = nodeRadius
304 |
305 | ctx.stroke()
306 |
307 |
308 | // -- draw nodes --
309 |
310 | var node = document.createElement("canvas")
311 | node.width = scale * nodeRadius * 8 * r
312 | node.height = node.width
313 |
314 | var nctx = node.getContext("2d")
315 | nctx.scale(scale * r, scale * r)
316 | nctx.save()
317 |
318 | nctx.translate(-node.width / scale, -node.height / scale)
319 | nctx.lineWidth = nodeRadius
320 |
321 | nctx.beginPath()
322 | nctx.moveTo(nodeRadius, 0)
323 | nctx.arc(0, 0, nodeRadius, 0, 2 * Math.PI)
324 |
325 | nctx.strokeStyle = "rgba(255, 0, 0, 1)"
326 | nctx.shadowOffsetX = node.width * 1.5 + 0
327 | nctx.shadowOffsetY = node.height * 1.5 + 3
328 | nctx.shadowBlur = 12
329 | nctx.shadowColor = "rgba(0, 0, 0, 0.16)"
330 | nctx.stroke()
331 | nctx.shadowOffsetX = node.width * 1.5 + 0
332 | nctx.shadowOffsetY = node.height * 1.5 + 3
333 | nctx.shadowBlur = 12
334 | nctx.shadowColor = "rgba(0, 0, 0, 0.23)"
335 | nctx.stroke()
336 |
337 | nctx.restore()
338 | nctx.translate(node.width / 2 / scale / r, node.height / 2 / scale / r)
339 |
340 | nctx.beginPath()
341 | nctx.moveTo(nodeRadius, 0)
342 | nctx.arc(0, 0, nodeRadius, 0, 2 * Math.PI)
343 |
344 | nctx.strokeStyle = nodeColor
345 | nctx.lineWidth = nodeRadius
346 | nctx.stroke()
347 |
348 | ctx.save()
349 | ctx.scale(1 / scale / r, 1 / scale / r)
350 | nodes.filter(visibleNodes).forEach(function (d) {
351 | ctx.drawImage(node, scale * r * d.x - node.width / 2, scale * r * d.y - node.height / 2)
352 | })
353 | ctx.restore()
354 |
355 | // -- draw clients --
356 | ctx.save()
357 | ctx.beginPath()
358 | nodes.filter(visibleNodes).forEach(function (d) {
359 | var clients = d.o.node.statistics.clients
360 | if (clients === 0)
361 | return
362 |
363 | var startDistance = 16
364 | var radius = 3
365 | var a = 1.2
366 | var startAngle = Math.PI
367 |
368 | for (var orbit = 0, i = 0; i < clients; orbit++) {
369 | var distance = startDistance + orbit * 2 * radius * a
370 | var n = Math.floor((Math.PI * distance) / (a * radius))
371 | var delta = clients - i
372 |
373 | for (var j = 0; j < Math.min(delta, n); i++, j++) {
374 | var angle = 2 * Math.PI / n * j
375 | var x = d.x + distance * Math.cos(angle + startAngle)
376 | var y = d.y + distance * Math.sin(angle + startAngle)
377 |
378 | ctx.moveTo(x, y)
379 | ctx.arc(x, y, radius, 0, 2 * Math.PI)
380 | }
381 | }
382 | })
383 |
384 | ctx.fillStyle = clientColor
385 | ctx.fill()
386 | ctx.restore()
387 |
388 | // -- draw node highlights --
389 | if (highlightedNodes.length) {
390 | ctx.save()
391 | ctx.shadowColor = "rgba(255, 255, 255, 1.0)"
392 | ctx.shadowBlur = 10 * nodeRadius
393 | ctx.shadowOffsetX = 0
394 | ctx.shadowOffsetY = 0
395 | ctx.globalCompositeOperation = "lighten"
396 | ctx.fillStyle = highlightColor
397 |
398 | ctx.beginPath()
399 | highlightedNodes.forEach(function (d) {
400 | ctx.moveTo(d.x + 5 * nodeRadius, d.y)
401 | ctx.arc(d.x, d.y, 5 * nodeRadius, 0, 2 * Math.PI)
402 | })
403 | ctx.fill()
404 |
405 | ctx.restore()
406 | }
407 |
408 | // -- draw link highlights --
409 | if (highlightedLinks.length) {
410 | ctx.save()
411 | ctx.lineWidth = 2 * 5 * nodeRadius
412 | ctx.shadowColor = "rgba(255, 255, 255, 1.0)"
413 | ctx.shadowBlur = 10 * nodeRadius
414 | ctx.shadowOffsetX = 0
415 | ctx.shadowOffsetY = 0
416 | ctx.globalCompositeOperation = "lighten"
417 | ctx.strokeStyle = highlightColor
418 | ctx.lineCap = "round"
419 |
420 | ctx.beginPath()
421 | highlightedLinks.forEach(function (d) {
422 | ctx.moveTo(d.source.x, d.source.y)
423 | ctx.lineTo(d.target.x, d.target.y)
424 | })
425 | ctx.stroke()
426 |
427 | ctx.restore()
428 | }
429 |
430 | // -- draw labels --
431 | if (scale > 0.9)
432 | intNodes.filter(visibleNodes).forEach(drawLabel, scale)
433 |
434 | ctx.restore()
435 | }
436 |
437 | function tickEvent() {
438 | redraw()
439 | }
440 |
441 | function resizeCanvas() {
442 | var r = window.devicePixelRatio
443 | canvas.width = el.offsetWidth * r
444 | canvas.height = el.offsetHeight * r
445 | canvas.style.width = el.offsetWidth + "px"
446 | canvas.style.height = el.offsetHeight + "px"
447 | ctx.setTransform(1, 0, 0, 1, 0, 0)
448 | ctx.scale(r, r)
449 | requestAnimationFrame(redraw)
450 | }
451 |
452 | function distance(a, b) {
453 | return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)
454 | }
455 |
456 | function distancePoint(a, b) {
457 | return Math.sqrt(distance(a, b))
458 | }
459 |
460 | function distanceLink(p, a, b) {
461 | /* http://stackoverflow.com/questions/849211 */
462 |
463 | var l2 = distance(a, b)
464 |
465 | if (l2 === 0)
466 | return distance(p, a)
467 |
468 | var t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2
469 |
470 | if (t < 0)
471 | return distance(p, a)
472 |
473 | if (t > 1)
474 | return distance(p, b)
475 |
476 | return Math.sqrt(distance(p, { x: a.x + t * (b.x - a.x),
477 | y: a.y + t * (b.y - a.y) }))
478 | }
479 |
480 | function translateXY(d) {
481 | var translate = zoomBehavior.translate()
482 | var scale = zoomBehavior.scale()
483 |
484 | return {x: (d[0] - translate[0]) / scale,
485 | y: (d[1] - translate[1]) / scale
486 | }
487 | }
488 |
489 | function onClick() {
490 | if (d3.event.defaultPrevented)
491 | return
492 |
493 | var e = translateXY(d3.mouse(el))
494 |
495 | var nodes = intNodes.filter(function (d) {
496 | return distancePoint(e, d) < NODE_RADIUS
497 | })
498 |
499 | if (nodes.length > 0) {
500 | router.node(nodes[0].o.node)()
501 | return
502 | }
503 |
504 | var links = intLinks.filter(function (d) {
505 | return !d.o.vpn
506 | }).filter(function (d) {
507 | return distanceLink(e, d.source, d.target) < LINE_RADIUS
508 | })
509 |
510 | if (links.length > 0) {
511 | router.link(links[0].o)()
512 | return
513 | }
514 | }
515 |
516 | function zoom(z, scale) {
517 | var size = getSize()
518 | var newSize = [size[0] / scale, size[1] / scale]
519 |
520 | var sidebarWidth = sidebar()
521 | var delta = [size[0] - newSize[0], size[1] - newSize[1]]
522 | var translate = z.translate()
523 | var translateNew = [sidebarWidth + (translate[0] - sidebarWidth - delta[0] / 2) * scale, (translate[1] - delta[1] / 2) * scale]
524 |
525 | animatePanzoom(translateNew, z.scale() * scale)
526 | }
527 |
528 | function keyboardZoom(z) {
529 | return function () {
530 | var e = d3.event
531 |
532 | if (e.altKey || e.ctrlKey || e.metaKey)
533 | return
534 |
535 | if (e.keyCode === 43)
536 | zoom(z, 1.41)
537 |
538 | if (e.keyCode === 45)
539 | zoom(z, 1 / 1.41)
540 | }
541 | }
542 |
543 | el = document.createElement("div")
544 | el.classList.add("graph")
545 |
546 | zoomBehavior = d3.behavior.zoom()
547 | .scaleExtent([1 / 3, 3])
548 | .on("zoom", onPanZoom)
549 | .translate([sidebar(), 0])
550 |
551 | canvas = d3.select(el)
552 | .attr("tabindex", 1)
553 | .on("keypress", keyboardZoom(zoomBehavior))
554 | .call(zoomBehavior)
555 | .append("canvas")
556 | .on("click", onClick)
557 | .call(draggableNode)
558 | .node()
559 |
560 | ctx = canvas.getContext("2d")
561 |
562 | force = d3.layout.force()
563 | .charge(-250)
564 | .gravity(0.1)
565 | .linkDistance(function (d) {
566 | if (d.o.vpn)
567 | return 0
568 | else
569 | return LINK_DISTANCE
570 | })
571 | .linkStrength(function (d) {
572 | if (d.o.vpn)
573 | return 0
574 | else
575 | return Math.max(0.5, 1 / d.o.tq)
576 | })
577 | .on("tick", tickEvent)
578 | .on("end", savePositions)
579 |
580 | window.addEventListener("resize", resizeCanvas)
581 |
582 | panzoom()
583 |
584 | self.setData = function (data) {
585 | var oldNodes = {}
586 |
587 | intNodes.forEach( function (d) {
588 | oldNodes[d.o.id] = d
589 | })
590 |
591 | intNodes = data.graph.nodes.map( function (d) {
592 | var e
593 | if (d.id in oldNodes)
594 | e = oldNodes[d.id]
595 | else
596 | e = {}
597 |
598 | e.o = d
599 |
600 | return e
601 | })
602 |
603 | var newNodesDict = {}
604 |
605 | intNodes.forEach( function (d) {
606 | newNodesDict[d.o.id] = d
607 | })
608 |
609 | var oldLinks = {}
610 |
611 | intLinks.forEach( function (d) {
612 | oldLinks[d.o.id] = d
613 | })
614 |
615 | intLinks = data.graph.links.map( function (d) {
616 | var e
617 | if (d.id in oldLinks)
618 | e = oldLinks[d.id]
619 | else
620 | e = {}
621 |
622 | e.o = d
623 | e.source = newNodesDict[d.source.id]
624 | e.target = newNodesDict[d.target.id]
625 |
626 | if (d.vpn)
627 | e.color = "rgba(255, 255, 255, " + (0.6 / d.tq) + ")"
628 | else
629 | e.color = linkScale(d.tq).hex()
630 |
631 | return e
632 | })
633 |
634 | linksDict = {}
635 | nodesDict = {}
636 |
637 | intNodes.forEach(function (d) {
638 | d.neighbours = {}
639 |
640 | if (d.o.node)
641 | nodesDict[d.o.node.nodeinfo.node_id] = d
642 |
643 | var name = nodeName(d)
644 |
645 | var offset = 5
646 | var lineWidth = 3
647 | var buffer = document.createElement("canvas")
648 | var r = window.devicePixelRatio
649 | var bctx = buffer.getContext("2d")
650 | bctx.font = "11px Roboto"
651 | var width = bctx.measureText(name).width
652 | var scale = zoomBehavior.scaleExtent()[1] * r
653 | buffer.width = (width + 2 * lineWidth) * scale
654 | buffer.height = (16 + 2 * lineWidth) * scale
655 | bctx.scale(scale, scale)
656 | bctx.textBaseline = "middle"
657 | bctx.textAlign = "center"
658 | bctx.fillStyle = "rgba(242, 227, 198, 1.0)"
659 | bctx.shadowColor = "rgba(0, 0, 0, 1)"
660 | bctx.shadowBlur = 5
661 | bctx.fillText(name, buffer.width / (2 * scale), buffer.height / (2 * scale))
662 |
663 | d.label = buffer
664 | d.labelWidth = buffer.width / scale
665 | d.labelHeight = buffer.height / scale
666 | d.labelA = offset + buffer.width / (2 * scale)
667 | d.labelB = offset + buffer.height / (2 * scale)
668 | })
669 |
670 | intLinks.forEach(function (d) {
671 | d.source.neighbours[d.target.o.id] = {node: d.target, link: d}
672 | d.target.neighbours[d.source.o.id] = {node: d.source, link: d}
673 |
674 | if (d.o.source.node && d.o.target.node)
675 | linksDict[d.o.id] = d
676 | })
677 |
678 | intNodes.forEach(function (d) {
679 | d.neighbours = Object.keys(d.neighbours).map(function (k) {
680 | return d.neighbours[k]
681 | })
682 | })
683 |
684 | nodes = intNodes.filter(function (d) { return d.o.node })
685 | unknownNodes = intNodes.filter(function (d) { return !d.o.node })
686 |
687 | if (localStorageTest()) {
688 | var save = JSON.parse(localStorage.getItem("graph/nodeposition"))
689 |
690 | if (save) {
691 | var nodePositions = {}
692 | save.forEach( function (d) {
693 | nodePositions[d.id] = d
694 | })
695 |
696 | intNodes.forEach( function (d) {
697 | if (nodePositions[d.o.id] && (d.x === undefined || d.y === undefined)) {
698 | d.x = nodePositions[d.o.id].x
699 | d.y = nodePositions[d.o.id].y
700 | }
701 | })
702 | }
703 | }
704 |
705 | var diameter = graphDiameter(intNodes)
706 |
707 | force.nodes(intNodes)
708 | .links(intLinks)
709 | .size([diameter, diameter])
710 |
711 | updateHighlight(true)
712 |
713 | force.start()
714 | resizeCanvas()
715 | }
716 |
717 | self.resetView = function () {
718 | highlight = undefined
719 | updateHighlight()
720 | doAnimation = true
721 | }
722 |
723 | self.gotoNode = function (d) {
724 | highlight = {type: "node", o: d}
725 | updateHighlight()
726 | doAnimation = true
727 | }
728 |
729 | self.gotoLink = function (d) {
730 | highlight = {type: "link", o: d}
731 | updateHighlight()
732 | doAnimation = true
733 | }
734 |
735 | self.destroy = function () {
736 | force.stop()
737 | canvas.remove()
738 | force = null
739 |
740 | if (el.parentNode)
741 | el.parentNode.removeChild(el)
742 | }
743 |
744 | self.render = function (d) {
745 | d.appendChild(el)
746 | resizeCanvas()
747 | updateHighlight()
748 | }
749 |
750 | return self
751 | }
752 | })
753 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.