├── 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 Schneider " 32 | ], 33 | "license": "GPL3", 34 | "private": true 35 | } 36 | -------------------------------------------------------------------------------- /scss/_map.scss: -------------------------------------------------------------------------------- 1 | .stroke-first { 2 | paint-order: stroke; 3 | } 4 | 5 | .map { 6 | width: 100%; 7 | height: 100%; 8 | 9 | .node-alert { 10 | -webkit-animation: blink 2s linear; 11 | -webkit-animation-iteration-count: infinite; 12 | animation: blink 2s linear; 13 | animation-iteration-count: infinite; 14 | } 15 | 16 | .leaflet-top button.leaflet-control { 17 | margin-top: $buttondistance; 18 | } 19 | 20 | .leaflet-bottom button.leaflet-control { 21 | margin-bottom: $buttondistance; 22 | } 23 | 24 | .leaflet-left button.leaflet-control { 25 | margin-left: $buttondistance; 26 | } 27 | 28 | .leaflet-right button.leaflet-control { 29 | margin-right: $buttondistance; 30 | } 31 | } 32 | 33 | @-webkit-keyframes blink { 34 | 0% { opacity: 1.0; } 35 | 80% { opacity: 1.0; } 36 | 90% { opacity: 0.0; } 37 | } 38 | 39 | @keyframes blink { 40 | 0% { opacity: 1.0; } 41 | 80% { opacity: 1.0; } 42 | 90% { opacity: 0.0; } 43 | } 44 | -------------------------------------------------------------------------------- /tasks/linting.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.config.merge({ 3 | checkDependencies: { 4 | options: { 5 | install: true 6 | }, 7 | bower: { 8 | options: { 9 | packageManager: "bower" 10 | } 11 | }, 12 | npm: {} 13 | }, 14 | eslint: { 15 | options: { 16 | rules: { 17 | "semi": [2, "never"], 18 | "curly": [2, "multi"], 19 | "strict": [2, "never"], 20 | "no-multi-spaces": 0, 21 | "no-new": 0, 22 | "no-shadow": 0, 23 | "no-use-before-define": [1, "nofunc"], 24 | "no-underscore-dangle": 0 25 | } 26 | }, 27 | sources: { 28 | src: ["app.js", "!Gruntfile.js", "lib/**/*.js"] 29 | }, 30 | grunt: { 31 | src: ["Gruntfile.js", "tasks/*.js"] 32 | } 33 | } 34 | }) 35 | 36 | grunt.loadNpmTasks("grunt-check-dependencies") 37 | grunt.loadNpmTasks("grunt-eslint") 38 | } 39 | -------------------------------------------------------------------------------- /lib/infobox/link.js: -------------------------------------------------------------------------------- 1 | define(function () { 2 | return function (config, el, router, d) { 3 | var h2 = document.createElement("h2") 4 | var a1 = document.createElement("a") 5 | a1.href = "#" 6 | a1.onclick = router.node(d.source.node) 7 | a1.textContent = d.source.node.nodeinfo.hostname 8 | h2.appendChild(a1) 9 | h2.appendChild(document.createTextNode(" – ")) 10 | var a2 = document.createElement("a") 11 | a2.href = "#" 12 | a2.onclick = router.node(d.target.node) 13 | a2.textContent = d.target.node.nodeinfo.hostname 14 | h2.appendChild(a2) 15 | el.appendChild(h2) 16 | 17 | var attributes = document.createElement("table") 18 | attributes.classList.add("attributes") 19 | 20 | attributeEntry(attributes, "TQ", showTq(d)) 21 | attributeEntry(attributes, "Entfernung", showDistance(d)) 22 | attributeEntry(attributes, "VPN", d.vpn ? "ja" : "nein") 23 | var hw1 = dictGet(d.source.node.nodeinfo, ["hardware", "model"]) 24 | var hw2 = dictGet(d.target.node.nodeinfo, ["hardware", "model"]) 25 | attributeEntry(attributes, "Hardware", (hw1 != null ? hw1 : "unbekannt") + " – " + (hw2 != null ? hw2 : "unbekannt")) 26 | 27 | el.appendChild(attributes) 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /lib/infobox/main.js: -------------------------------------------------------------------------------- 1 | define(["infobox/link", "infobox/node"], function (Link, Node) { 2 | return function (config, sidebar, router) { 3 | var self = this 4 | var el 5 | 6 | function destroy() { 7 | if (el && el.parentNode) { 8 | el.parentNode.removeChild(el) 9 | el = undefined 10 | sidebar.reveal() 11 | } 12 | } 13 | 14 | function create() { 15 | destroy() 16 | sidebar.ensureVisible() 17 | sidebar.hide() 18 | 19 | el = document.createElement("div") 20 | sidebar.container.insertBefore(el, sidebar.container.firstChild) 21 | 22 | el.scrollIntoView(false) 23 | el.classList.add("infobox") 24 | el.destroy = destroy 25 | 26 | var closeButton = document.createElement("button") 27 | closeButton.classList.add("close") 28 | closeButton.onclick = router.reset 29 | el.appendChild(closeButton) 30 | } 31 | 32 | self.resetView = destroy 33 | 34 | self.gotoNode = function (d) { 35 | create() 36 | new Node(config, el, router, d) 37 | } 38 | 39 | self.gotoLink = function (d) { 40 | create() 41 | new Link(config, el, router, d) 42 | } 43 | 44 | return self 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /lib/sidebar.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | return function (el) { 3 | var self = this 4 | 5 | var sidebar = document.createElement("div") 6 | sidebar.classList.add("sidebar") 7 | el.appendChild(sidebar) 8 | 9 | var button = document.createElement("button") 10 | sidebar.appendChild(button) 11 | 12 | button.classList.add("sidebarhandle") 13 | button.onclick = function () { 14 | sidebar.classList.toggle("hidden") 15 | } 16 | 17 | var container = document.createElement("div") 18 | container.classList.add("container") 19 | sidebar.appendChild(container) 20 | 21 | self.getWidth = function () { 22 | if (sidebar.classList.contains("hidden")) 23 | return 0 24 | 25 | var small = window.matchMedia("(max-width: 630pt)") 26 | return small.matches ? 0 : sidebar.offsetWidth 27 | } 28 | 29 | self.add = function (d) { 30 | d.render(container) 31 | } 32 | 33 | self.ensureVisible = function () { 34 | sidebar.classList.remove("hidden") 35 | } 36 | 37 | self.hide = function () { 38 | container.classList.add("hidden") 39 | } 40 | 41 | self.reveal = function () { 42 | container.classList.remove("hidden") 43 | } 44 | 45 | self.container = sidebar 46 | 47 | return self 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /scss/_reset.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/) 3 | * http://cssreset.com 4 | */ 5 | html, body, div, span, applet, object, iframe, 6 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 7 | a, abbr, acronym, address, big, cite, code, 8 | del, dfn, em, img, ins, kbd, q, s, samp, 9 | small, strike, strong, sub, sup, tt, var, 10 | b, u, i, center, 11 | dl, dt, dd, ol, ul, li, 12 | fieldset, form, label, legend, 13 | table, caption, tbody, tfoot, thead, tr, th, td, 14 | article, aside, canvas, details, embed, 15 | figure, figcaption, footer, header, hgroup, 16 | menu, nav, output, ruby, section, summary, 17 | time, mark, audio, video { 18 | margin: 0; 19 | padding: 0; 20 | border: 0; 21 | font-size: 100%; 22 | font: inherit; 23 | vertical-align: baseline; 24 | } 25 | /* HTML5 display-role reset for older browsers */ 26 | article, aside, details, figcaption, figure, 27 | footer, header, hgroup, menu, nav, section { 28 | display: block; 29 | } 30 | body { 31 | line-height: 1; 32 | } 33 | ol, ul { 34 | list-style: none; 35 | } 36 | blockquote, q { 37 | quotes: none; 38 | } 39 | blockquote:before, blockquote:after, 40 | q:before, q:after { 41 | content: ''; 42 | content: none; 43 | } 44 | table { 45 | border-collapse: collapse; 46 | border-spacing: 0; 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshviewer", 3 | "scripts": { 4 | "test": "node -e \"require('grunt').cli()\" '' clean lint" 5 | }, 6 | "devDependencies": { 7 | "grunt": "^1.4.0", 8 | "grunt-check-dependencies": "^0.6.0", 9 | "grunt-contrib-clean": "^0.6.0", 10 | "grunt-contrib-connect": "^0.8.0", 11 | "grunt-contrib-copy": "^0.5.0", 12 | "grunt-contrib-cssmin": "^0.12.3", 13 | "grunt-contrib-requirejs": "^0.4.4", 14 | "grunt-contrib-sass": "^0.9.2", 15 | "grunt-contrib-uglify": "^0.5.1", 16 | "grunt-contrib-watch": "^0.6.1", 17 | "grunt-eslint": "^10.0.0", 18 | "grunt-bower-install-simple": "^1.2.8", 19 | "grunt-git-describe": "^2.4.4" 20 | }, 21 | "eslintConfig": { 22 | "env": { 23 | "browser": true, 24 | "amd": true, 25 | "es6": true, 26 | "node": true 27 | }, 28 | "globals": { 29 | "attributeEntry": false, 30 | "dictGet": false, 31 | "getJSON": false, 32 | "has_location": false, 33 | "limit": false, 34 | "localStorageTest": false, 35 | "offline": false, 36 | "one": false, 37 | "online": false, 38 | "showDistance": false, 39 | "showTq": false, 40 | "sortByKey": false, 41 | "subtract": false, 42 | "sum": false, 43 | "trueDefault": false 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require.config({ 2 | baseUrl: "lib", 3 | paths: { 4 | "leaflet": "../bower_components/leaflet/dist/leaflet", 5 | "leaflet.label": "../bower_components/Leaflet.label/dist/leaflet.label", 6 | "leaflet.providers": "../bower_components/leaflet-providers/leaflet-providers", 7 | "chroma-js": "../bower_components/chroma-js/chroma.min", 8 | "moment": "../bower_components/moment/min/moment-with-locales.min", 9 | "tablesort": "../bower_components/tablesort/tablesort.min", 10 | "tablesort.numeric": "../bower_components/tablesort/src/sorts/tablesort.numeric", 11 | "d3": "../bower_components/d3/d3.min", 12 | "numeral": "../bower_components/numeraljs/min/numeral.min", 13 | "numeral-intl": "../bower_components/numeraljs/min/languages.min", 14 | "virtual-dom": "../bower_components/virtual-dom/dist/virtual-dom", 15 | "rbush": "../bower_components/rbush/rbush", 16 | "helper": "../helper", 17 | "jshashes": "../bower_components/jshashes/hashes" 18 | }, 19 | shim: { 20 | "leaflet.label": ["leaflet"], 21 | "leaflet.providers": ["leaflet"], 22 | "tablesort": { 23 | exports: "Tablesort" 24 | }, 25 | "numeral-intl": { 26 | deps: ["numeral"], 27 | exports: "numeral" 28 | }, 29 | "tablesort.numeric": ["tablesort"], 30 | "helper": ["numeral-intl"] 31 | } 32 | }) 33 | 34 | require(["main", "helper"], function (main) { 35 | getJSON("config.json").then(main) 36 | }) 37 | -------------------------------------------------------------------------------- /lib/tabs.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | return function () { 3 | var self = this 4 | 5 | var tabs = document.createElement("ul") 6 | tabs.classList.add("tabs") 7 | 8 | var container = document.createElement("div") 9 | 10 | function gotoTab(li) { 11 | for (var i = 0; i < tabs.children.length; i++) 12 | tabs.children[i].classList.remove("visible") 13 | 14 | while (container.firstChild) 15 | container.removeChild(container.firstChild) 16 | 17 | li.classList.add("visible") 18 | 19 | var tab = document.createElement("div") 20 | tab.classList.add("tab") 21 | container.appendChild(tab) 22 | li.child.render(tab) 23 | } 24 | 25 | function switchTab() { 26 | gotoTab(this) 27 | 28 | return false 29 | } 30 | 31 | self.add = function (title, d) { 32 | var li = document.createElement("li") 33 | li.textContent = title 34 | li.onclick = switchTab 35 | li.child = d 36 | tabs.appendChild(li) 37 | 38 | var anyVisible = false 39 | 40 | for (var i = 0; i < tabs.children.length; i++) 41 | if (tabs.children[i].classList.contains("visible")) { 42 | anyVisible = true 43 | break 44 | } 45 | 46 | if (!anyVisible) 47 | gotoTab(li) 48 | } 49 | 50 | self.render = function (el) { 51 | el.appendChild(tabs) 52 | el.appendChild(container) 53 | } 54 | 55 | return self 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /lib/about.js: -------------------------------------------------------------------------------- 1 | define(function () { 2 | return function() { 3 | this.render = function (d) { 4 | var el = document.createElement("div") 5 | d.appendChild(el) 6 | var s = "

Über meshviewer

" 7 | 8 | s += "

Mit Doppelklick und Shift+Doppelklick kann man in der Karte " 9 | s += "auch zoomen.

" 10 | 11 | s += "

AGPL 3

" 12 | 13 | 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 | [![Build Status](https://travis-ci.org/ffnord/meshviewer.svg?branch=master)](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 | ![](doc/mapview.png?raw=true) 25 | ![](doc/graphview.png?raw=true) 26 | ![](doc/allnodes.png?raw=true) 27 | ![](doc/links.png?raw=true) 28 | ![](doc/statistics.png?raw=true) 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. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------