lightbeam/data/graph.js

422 строки
12 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Graph Visualization (one of 3 views: graph, clock, and list). This is way
// too heavyweight for mobile right now.
// Visualization of tracking data interconnections
(function (visualizations, global) {
"use strict";
// Bunch of utilities related to UI elements.
const graphNodeRadius = {
"graph": 12
};
// The graph is an emitter with a default size.
var graph = new Emitter();
visualizations.graph = graph;
graph.name = "graph";
var width = 750,
height = 750;
var force, vis;
var edges, nodes;
// There are three phases for a visualization life-cycle:
// init does initialization and receives the existing set of connections
// connection notifies of a new connection that matches existing filter
// remove lets the visualization know it is about to be switched out so it can clean up
graph.on('init', onInit);
// graph.on('connection', onConnection);
graph.on('remove', onRemove);
graph.on('reset', onReset);
/* for Highlighting and Colouring -------------------- */
var highlight = {
visited: true,
neverVisited: true,
connections: true,
cookies: true,
watched: true,
blocked: true
};
// Restart the simulation. This is only called when there's a new connection we
// haven't seen before.
function onUpdate() {
// new nodes, reheat graph simulation
if (force) {
console.debug('restarting graph due to update');
force.stop();
force.nodes(filteredAggregate.nodes);
force.links(filteredAggregate.edges);
force.start();
updateGraph();
colourHighlightNodes(highlight);
} else {
console.debug('the force is not with us');
}
}
function onInit() {
console.debug('graph::onInit()');
console.debug('initializing graph from ' + filteredAggregate.nodes.length +
' connections');
// Handles all of the panning and scaling.
vis = d3.select(vizcanvas);
// A D3 visualization has a two main components, data-shaping, and setting up the D3 callbacks
// This binds our data to the D3 visualization and sets up the callbacks
initGraph();
aggregate.on('update', onUpdate);
// Different visualizations may have different viewBoxes, so make sure we use the right one
vizcanvas.setAttribute('viewBox', [0, 0, width, height].join(' '));
console.debug('graph::onInit end');
document.querySelector(".filter-display").classList.remove("hidden");
}
function onRemove() {
var startTime = Date.now();
if (force) {
force.stop();
force = null;
}
resetCanvas();
document.querySelector(".filter-display").classList.add("hidden");
console.debug('it took %s ms to remove graph view', Date.now() - startTime);
}
function onReset() {
onRemove();
aggregate.emit('load', global.allConnections);
}
// UTILITIES FOR CREATING POLYGONS
function point(angle, size) {
return [Math.round(Math.cos(angle) * size), -Math.round(Math.sin(angle) * size)];
}
function polygon(points, size, debug) {
var increment = Math.PI * 2 / points;
var angles = [],
i;
for (i = 0; i < points; i++) {
angles.push(i * increment + Math.PI / 2); // add 90 degrees so first point is up
}
return angles.map(function (angle) {
return point(angle, size);
});
}
function polygonAsString(points, size) {
var poly = polygon(points, size);
return poly.map(function (pair) {
return pair.join(',');
}).join(' ');
}
// ACCESSOR FUNCTIONS
// function scaleNode(node){ return 'translate(' + node.x + ',' + node.y + ') scale(' + (1 + .05 * node.weight) + ')'; }
function visited(node) {
return node.nodeType === 'site' || node.nodeType === 'both';
}
function notVisited(node) {
return node.nodeType === 'thirdparty';
}
// function timestamp(node){ return node.lastAccess.toISOString(); }
// function nodeHighlight(node){ return ( node.visitedCount > 0 ) ? highlight.highlightVisited : highlight.highlightNeverVisited; }
// function sourceX(edge){ return edge.source.x; }
// function sourceY(edge){ return edge.source.y; }
// function targetX(edge){ return edge.target.x; }
// function targetY(edge){ return edge.target.y; }
// function edgeCookie(edge){ return edge.cookieCount > 0; }
// function edgeHighlight(edge){ return highlight.connections; }
// function edgeColoured(edge){ return edge.cookieCount > 0 && highlight.cookies; }
function nodeName(node) {
if (node) {
return node.name;
}
return undefined;
}
function siteHasPref(site, pref) {
return (userSettings.hasOwnProperty(site) &&
userSettings[site].contains(pref));
}
function watchSite(node) {
return siteHasPref(node.name, "watch");
}
function blockSite(node) {
return siteHasPref(node.name, "block");
}
// SET UP D3 HANDLERS
var ticking = false;
function charge(d) {
return -(500 + d.weight * 25);
}
function colourHighlightNodes(highlight) {
var i;
var watchedSites = document.querySelectorAll(".watched");
var blockedSites = document.querySelectorAll(".blocked");
if (highlight.watched) {
for (i = 0; i < watchedSites.length; i++) {
watchedSites[i].classList.add("watchedSites");
}
} else {
for (i = 0; i < watchedSites.length; i++) {
watchedSites[i].classList.remove("watchedSites");
}
}
if (highlight.blocked) {
for (i = 0; i < blockedSites.length; i++) {
blockedSites[i].classList.add("blockedSites");
}
} else {
for (i = 0; i < blockedSites.length; i++) {
blockedSites[i].classList.remove("blockedSites");
}
}
}
function initGraph() {
// Initialize D3 layout and bind data
console.debug('initGraph()');
force = d3.layout.force()
.nodes(filteredAggregate.nodes)
.links(filteredAggregate.edges)
.charge(charge)
.size([width, height])
.start();
updateGraph();
// Terrible hack. Something about the d3 setup is wrong, and forcing onClick
// causes d3 to redraw in a way that most of the graph is visible on screen.
global.helpOnClick();
global.helpOnClick();
colourHighlightNodes(highlight);
// update method
var lastUpdate, lastTick;
lastUpdate = lastTick = Date.now();
var draws = [];
var ticks = 0;
const second = 1000;
const minute = 60 * second;
force.on('tick', function ontick(evt) {
// find a way to report how often tick() is called, and how long it takes to run
// without trying to console.log() every 5 milliseconds...
if (ticking) {
console.log('overlapping tick!');
return;
}
ticking = true;
var nextTick = Date.now();
ticks++;
lastTick = nextTick;
if ((lastTick - lastUpdate) > second) {
// console.log('%s ticks per second, each draw takes %s milliseconds', ticks, Math.floor(d3.mean(draws)));
lastUpdate = lastTick;
draws = [];
ticks = 0;
}
edges.each(function (d, i) {
// `this` is the DOM node
this.setAttribute('x1', d.source.x);
this.setAttribute('y1', d.source.y);
this.setAttribute('x2', d.target.x);
this.setAttribute('y2', d.target.y);
if (d.cookieCount) {
this.classList.add('cookieYes');
} else {
this.classList.remove('cookieYes');
}
if (highlight.connections) {
this.classList.add('highlighted');
} else {
this.classList.remove('highlighted');
}
if (d.cookieCount && highlight.cookies) {
this.classList.add('coloured');
} else {
this.classList.remove('coloured');
}
});
nodes.each(function (d, i) {
// `this` is the DOM node
this.setAttribute('transform', 'translate(' + d.x + ',' + d.y + ') scale(' + (1 + 0.05 * d.weight) + ')');
this.setAttribute('data-timestamp', d.lastAccess.toISOString());
if (d.nodeType === 'site' || d.nodeType === 'both') {
this.classList.add('visitedYes');
this.classList.remove('visitedNo');
} else {
this.classList.add('visitedNo');
this.classList.remove('visitedYes');
}
if ((d.nodeType === 'site' || d.nodeType === 'both') && highlight.visited) {
this.classList.add('highlighted');
} else if ((d.nodeType === 'thirdparty') && highlight.neverVisited) {
this.classList.add('highlighted');
} else {
this.classList.remove('highlighted');
}
});
var endDraw = Date.now();
draws.push(endDraw - lastTick);
if (force) {
nodes.call(force.drag);
}
ticking = false;
});
}
function updateGraph() {
console.debug('updateGraph()');
// Data binding for links
edges = vis.selectAll('.edge')
.data(filteredAggregate.edges, nodeName);
edges.enter().insert('line', ':first-child')
.classed('edge', true);
edges.exit()
.remove();
nodes = vis.selectAll('.node')
.data(filteredAggregate.nodes, nodeName);
nodes.enter().append('g')
.classed('visitedYes', visited)
.classed('visitedNo', notVisited)
.classed("watched", watchSite)
.classed("blocked", blockSite)
.call(addShape)
.attr('data-name', nodeName)
.on('mouseenter', tooltip.show)
.on('mouseleave', tooltip.hide)
.classed('node', true);
nodes.exit()
.remove();
}
function addFavicon(selection) {
selection.append("svg:image")
.attr("class", "favicon")
.attr("width", "16") // move these to the favicon class in css
.attr("height", "16")
.attr("x", "-8") // offset to make 16x16 favicon appear centered
.attr("y", "-8")
.attr("xlink:href", function (node) {
return 'http://' + node.name + '/favicon.ico';
});
}
function addCircle(selection) {
selection
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', graphNodeRadius.graph)
.classed('site', true);
}
function addShape(selection) {
selection.filter('.visitedYes').call(addCircle).call(addFavicon);
selection.filter('.visitedNo').call(addTriangle).call(addFavicon);
}
function addTriangle(selection) {
selection
.append('polygon')
.attr('points', polygonAsString(3, 20))
.attr('data-name', function (node) {
return node.name;
});
}
// FIXME: Move this out of visualization so multiple visualizations can use it.
function resetCanvas() {
// You will still need to remove timer events
var parent = vizcanvas.parentNode;
var newcanvas = vizcanvas.cloneNode(false);
var vizcanvasDefs = document.querySelector(".vizcanvas defs").cloneNode(true);
newcanvas.appendChild(vizcanvasDefs);
parent.replaceChild(newcanvas, vizcanvas);
vizcanvas = newcanvas;
aggregate.off('update', onUpdate);
}
var graphLegend = document.querySelector(".graph-footer");
function legendBtnClickHandler(legendElm) {
legendElm.querySelector(".legend-controls").addEventListener("click", function (event) {
if (event.target.mozMatchesSelector(".btn, .btn *")) {
var btn = event.target;
while (btn.mozMatchesSelector('.btn *')) {
btn = btn.parentElement;
}
btn.classList.toggle("active");
}
});
}
legendBtnClickHandler(graphLegend);
graphLegend.querySelector(".legend-toggle-visited").addEventListener("click", function (event) {
var visited = document.querySelectorAll(".visitedYes");
toggleVizElements(visited, "highlighted");
highlight.visited = !highlight.visited;
});
graphLegend.querySelector(".legend-toggle-never-visited").addEventListener("click", function (event) {
var neverVisited = document.querySelectorAll(".visitedNo");
toggleVizElements(neverVisited, "highlighted");
highlight.neverVisited = !highlight.neverVisited;
});
graphLegend.querySelector(".legend-toggle-connections").addEventListener("click", function (event) {
var cookiesConnections = document.querySelectorAll(".edge");
toggleVizElements(cookiesConnections, "highlighted");
highlight.connections = !highlight.connections;
});
graphLegend.querySelector(".legend-toggle-cookies").addEventListener("click", function (event) {
var cookiesConnections = document.querySelectorAll(".cookieYes");
toggleVizElements(cookiesConnections, "coloured");
highlight.cookies = !highlight.cookies;
});
graphLegend.querySelector(".legend-toggle-watched").addEventListener("click", function (event) {
highlight.watched = !highlight.watched;
colourHighlightNodes(highlight);
});
graphLegend.querySelector(".legend-toggle-blocked").addEventListener("click", function (event) {
highlight.blocked = !highlight.blocked;
colourHighlightNodes(highlight);
});
graphLegend.querySelector(".legend-toggle").addEventListener("click", function (event) {
toggleLegendSection(event.target, graphLegend);
});
})(visualizations, this);