зеркало из https://github.com/mozilla/lightbeam.git
422 строки
12 KiB
JavaScript
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);
|