diff --git a/www/graph.css b/www/graph.css
index 2d7ef76..897445a 100644
--- a/www/graph.css
+++ b/www/graph.css
@@ -17,9 +17,9 @@
--color9: #577590;
--color10: #277da1;
--hover-color: var(--color1);
+ --hover-related-color: #d1bce9;
/* --hover-color: var(darkblue); */
--selected-color: var(--color1);
- --selected-color: var(--color1);
}
body {
@@ -41,16 +41,20 @@ svg.dragging {
}
#arrowHead {
- fill: #9df32c;
+ fill: #b0e99a;
}
#arrowHeadSelected {
fill: var(--hover-color);;
}
+#arrowHeadSelectedRelated {
+ fill: var(--hover-related-color);;
+}
svg .links line, svg .links path {
- stroke: #f3722c;
- stroke: #9df32c;
+ /* stroke: #f3722c; */
+ /* stroke: #9df32c; */
+ stroke: #b0e99a;
stroke-width: 6;
fill: none;
transition: stroke-width 1s;
@@ -58,6 +62,18 @@ svg .links line, svg .links path {
}
svg .links line.hover, svg .links path.hover {
+ stroke: var(--hover-color);
+ /* stroke-width: 12; */
+ marker-end: url(#arrowHeadSelected);
+}
+
+svg .links .linkedHover path{
+ stroke: var(--hover-related-color);
+ stroke-width: 12;
+ marker-end: url(#arrowHeadSelectedRelated);
+}
+
+svg .links .linkedSelected path{
stroke: var(--hover-color);
stroke-width: 12;
marker-end: url(#arrowHeadSelected);
@@ -67,10 +83,9 @@ svg.zoomed .links line, svg.zoomed .links path {
stroke-width: 2;
}
-svg.zoomed .links line, svg.zoomed .links path.hover {
- stroke-width: 2;
+/* svg.zoomed .links line, svg.zoomed .links path.hover {
stroke-width: 4;
-}
+} */
svg .title {
font-size: 200;
@@ -88,7 +103,7 @@ svg #countries .country.eu_country {
fill: black;
}
-svg #header #titlePath, svg #header #subtitlePath {
+svg #header #titlePath, svg #header #title2Path, svg #header #subtitlePath {
stroke: none;
fill: none;
}
@@ -104,8 +119,8 @@ svg #header text {
}
svg #header text:nth-of-type(2) {
- dominant-baseline: hanging;
- transform: translate(10px, 25px);
+ /* dominant-baseline: hanging; */
+ /* transform: translate(10px, 25px); */
}
svg #header text#subtitle {
@@ -131,13 +146,15 @@ svg #header text#subtitle {
/* font-size: 16pt; */
/*Set this in JS*/
transition: font-size .4s, opacity 1s;
- fill: white;
+ fill: white; /*also when hovering node*/
opacity: 1;
pointer-events: none;
/*prevent mouse glitches*/
}
-.node:not(:hover):not(.linkHover) text.nodeTitle.overlapping {
+/* .node:not(:hover):not(.linkHover) text.nodeTitle.overlapping { */
+.node text.nodeTitle.overlapping {
+ /* used to be shown on hover, but disabled now that we have a tooltip */
opacity: 0;
}
@@ -155,26 +172,30 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
/* Whenever a connected link is hovered */
-.node.linkHover circle, .node.linkHover path, label:hover .node path {
- fill: var(--hover-color) !important;
- stroke: var(--hover-color);
+.node.linkHover circle, .node.linkHover path, .node.linkedHover path, label:hover .node path {
+ fill: var(--hover-related-color) !important;
+ stroke: var(--hover-related-color);
stroke-width: 5px;
}
+.node.linkedSelected path {
+ fill: var(--hover-related-color) !important;
+ /* same as linkHover/linkedHover but without border */
+}
.node.linkHover text.nodeTitle.overlapping {
transition: opacity 0s;
}
-.node:hover circle, .node:hover path {
+.node:hover circle, .node:hover path, .node.selected path {
fill: var(--hover-color) !important;
stroke: var(--hover-color);
stroke-width: 5px;
}
-
+/*
.node:hover text {
transition: none;
fill: var(--hover-color);
-}
+} */
.node.selected circle, .node.selected path {
fill: var(--selected-color) !important;
@@ -248,6 +269,7 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
display: none;
}
+
#nodeInfo h2 {
margin: 0;
padding: 0;
@@ -258,6 +280,45 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
height: calc(100vh - 40px - 20px - 30px);
}
+#tooltip{
+ position:absolute;
+ z-index: 100;
+ opacity: 1;
+ transition: opacity .3s;
+ background:white;
+ padding: 20px 10px;
+ border-radius: 5px;
+ box-shadow: 2px 2px 5px rgba(0, 0, 0, .5);
+}
+
+#tooltip:not(.visible){
+ position:absolute;
+ z-index: 100;
+ opacity:0;
+ pointer-events: none;
+}
+
+#tooltip h3{
+ margin: 5px 0;
+ text-align: center;;
+}
+#tooltip .category{
+ display: block;
+ color: black;
+ text-align: center;;
+}
+#tooltip .category::before{
+ content:'· '
+}
+#tooltip .category::after{
+ content:' ·'
+}
+#tooltip .clickForMore{
+ display: block;
+ color: gray;
+ text-align: center;;
+}
+
#closeInfo {
cursor: pointer;
position: absolute;
@@ -283,6 +344,7 @@ header {
right: 0;
background: white;
padding: 10px;
+ border-top-left-radius: 5px;
}
h1 {
@@ -352,4 +414,31 @@ p.subtitle {
#alluvial .flow_label text {
font-size: 30;
+}
+
+
+
+body.light{
+ background:white;
+}
+body.light #map .borders{
+ stroke: white;
+}
+body.light svg #countries .country{
+ fill:white;
+ stroke:lightgray;
+ stroke-width: 5;;
+}
+body.light svg #countries .country.eu_country{
+ fill:rgb(235, 226, 236);
+}
+body.light .node text.nodeTitle {
+ fill:black;
+}
+
+body.light #arrowHead{
+ fill:#577590;
+}
+body.light svg .links line, body.light svg .links path {
+ stroke:#577590;
}
\ No newline at end of file
diff --git a/www/graph.js b/www/graph.js
index 7254319..53e752e 100644
--- a/www/graph.js
+++ b/www/graph.js
@@ -4,7 +4,7 @@ const CONFIG = {
'subtitle': "Connections in the European Union & beyond",
// 'nodeSize': 8,
'nodeRadius': 5,
- 'nodeRepositionPadding': 8,
+ 'nodeRepositionPadding': 10,
'baseUrl': 'https://www.securityvision.io/wiki/index.php/',
'dataUrl': 'result.json',
'preSimulate': false, // run simulation before starting, so we don't start with lines jumping around
@@ -51,7 +51,16 @@ const CONFIG = {
"Company": ["Managed by", "Provided by", "Developped by (institutions)"],
"Tech": ["Technologies Used", "Software Deployed"],
"Funding": ["Funded by"],
- }
+ },
+ "zoom": {
+ "scale_min": .2,
+ "scale_max": 20,
+ },
+
+ "cases": [
+ "Data-lab Burglary-Free Neighbourhood",
+ "Dragonfly Project",
+ ]
};
// let width = window.innerWidth;
@@ -59,7 +68,7 @@ const CONFIG = {
function getSymbolForCategories(classes) {
- if(!Array.isArray(classes)) {
+ if (!Array.isArray(classes)) {
classes = [classes];
}
if (classes.includes('Institution')) {
@@ -194,12 +203,19 @@ function getClasses(obj) {
return 'node ' + classes.join(' ');
}
+function getLinkId(link) {
+ return "link_" + link.nr;
+ // return "link-" + link.source.id + '-' + link.target.id + '-' + slugify(link.name);
+}
+
class NodeMap {
constructor(parent) {
this.root = d3.select(parent);
this.resizeEvent = window.addEventListener('resize', this.resize.bind(this));
+ this.tooltipEl = document.getElementById('tooltip');
+ this.selectedNode = null;
}
resize() {
@@ -247,7 +263,7 @@ class NodeMap {
render() {
this.svg = this.root.append('svg')
- this.svg.append('defs').html(`
+ this.svg.append('defs').html(`
@@ -315,34 +331,40 @@ class NodeMap {
.attr("d", this.proj(this.borders))
let zoomTimeout = null;
- const zoom = d3.zoom().scaleExtent([0.2, 10]).on("start", () => {
- this.svg.node().classList.add("dragging");
- }).on("end", () => {
- this.svg.node().classList.remove("dragging");
- }).on("zoom", ({ transform }) => {
- this.container.attr("transform", transform);
- const oldZoom = this.svg.classed('zoomed');
- const newZoom = transform.k > 2.0;
- if (zoomTimeout) {
- clearTimeout(zoomTimeout)
- }
- zoomTimeout = setTimeout(() => {
- this.g_nodes.attr('style', `font-size:${22000 / this.height / transform.k}pt`)
- setTimeout(() => {
- this.calculateLabels();
- }, 500);
- }, 500);
- if (oldZoom != newZoom) {
- this.svg.classed('zoomed', newZoom);
+ this.zoom = d3.zoom()
+ .scaleExtent([CONFIG.zoom.scale_min, CONFIG.zoom.scale_max])
+ .on("start", () => {
+ this.svg.node().classList.add("dragging");
+ }).on("end", () => {
+ this.svg.node().classList.remove("dragging");
+ }).on("zoom", (evt) => {
+ this.container.attr("transform", evt.transform);
+ const oldZoom = this.svg.classed('zoomed');
+ const newZoom = evt.transform.k > 2.0;
+ if (zoomTimeout) {
+ clearTimeout(zoomTimeout)
+ }
+ zoomTimeout = setTimeout(() => {
+ this.g_nodes.attr('style', `font-size:${22000 / this.height / evt.transform.k}pt`)
+ setTimeout(() => {
+ this.calculateLabels();
+ }, 500);
+ }, 250);
+ if (oldZoom != newZoom) {
+ this.svg.classed('zoomed', newZoom);
- }
- });
+ }
+ });
this.title = this.container.append('g').attr('id', 'header');
const titleFeature = {
"type": "LineString",
"coordinates": []
};
+ const title2Feature = {
+ "type": "LineString",
+ "coordinates": []
+ };
const subtitleFeature = {
"type": "LineString",
"coordinates": []
@@ -351,12 +373,17 @@ class NodeMap {
// projection apparently tries to find the shortest path between two points
// which is NOT following a lat/lon line on the globe
titleFeature.coordinates.push([index, 52]);
+ title2Feature.coordinates.push([index, 50.5]);
subtitleFeature.coordinates.push([index, 49]);
}
this.title.append("path")
.attr("id", "titlePath")
.attr("d", this.proj(titleFeature))
;
+ this.title.append("path")
+ .attr("id", "title2Path")
+ .attr("d", this.proj(title2Feature))
+ ;
this.title.append("path")
.attr("id", "subtitlePath")
.attr("d", this.proj(subtitleFeature))
@@ -364,7 +391,7 @@ class NodeMap {
this.title.append("text")
.html('Biometric')
this.title.append("text")
- .html('Mass Surveillance')
+ .html('Mass Surveillance')
this.title.append("text")
.attr("id", "subtitle")
.html('' + CONFIG.subtitle + '')
@@ -448,8 +475,8 @@ class NodeMap {
;
this.svg
- .call(zoom)
- .call(zoom.transform, d3.zoomIdentity.scale(.5, .5));
+ .call(this.zoom)
+ .call(this.zoom.transform, d3.zoomIdentity.scale(.5, .5));
this.update();
@@ -457,6 +484,14 @@ class NodeMap {
setTimeout(() => this.calculateLabels(), 1000);
}
+ resetZoom() {
+ this.deselectNode();
+ this.svg
+ .transition()
+ .duration(2000) // milliseconds
+ .call(this.zoom.transform, d3.zoomIdentity.scale(.5, .5));
+ }
+
getSizeForNode(node) {
return this.nodeSize;
}
@@ -532,17 +567,111 @@ class NodeMap {
console.log(`moved ${moved} nodes`);
}
+ showTooltip(el, node, links) {
+ // TODO: make links optional (otherwise collect links here)
+
+ this.tooltipEl.innerHTML = `
+ ${getCategories(node)[0]}
+ ${node.fulltext}
+ `;
+ if (links.length) {
+ const rels = links.length === 1 ? 'relationship' : 'relationships';
+ this.tooltipEl.innerHTML += `
+ Click to examine ${links.length} ${rels}
+ `;
+ }
+ const rect = el.getBoundingClientRect()
+ const rectTT = this.tooltipEl.getBoundingClientRect();
+ this.tooltipEl.style.top = (rect.top - rectTT.height) + 'px';
+ this.tooltipEl.style.left = (rect.left + rect.width / 2 - rectTT.width / 2) + 'px';
+ // console.log(el, node, rect.top);
+
+ this.tooltipEl.classList.add('visible');
+ }
+
+ hideTooltip() {
+ this.tooltipEl.classList.remove('visible');
+ }
+
+ selectNode(node) {
+ this.deselectNode(); // remove potential old selection
+
+ this.selectedNode = node;
+ let links = [];
+ let connectedNodes = [];
+ for (let link of this.graph.links) {
+ if (link.source == node || link.target == node) {
+ links.push(link);
+ const otherNode = node == link.target ? link.source : link.target;
+ connectedNodes.push(otherNode);
+ }
+ }
+
+ let allNodes = [...connectedNodes, node];
+
+ this.zoomFit(allNodes);
+
+ document.getElementById(node.id).classList.add('selected');
+ connectedNodes.forEach(n => document.getElementById(n.id).classList.add('linkedSelected'));
+ links.forEach(l => document.getElementById(getLinkId(l)).classList.add('linkedSelected'));
+
+ // TODO: show details;
+
+ // alert('not yet implemented');
+ }
+
+ deselectNode() {
+ this.selectedNode = null;
+ let nodeEls = document.getElementsByClassName('selected');
+ while (nodeEls.length) {
+ nodeEls[0].classList.remove('selected');
+ }
+ let els = document.getElementsByClassName('linkedSelected');
+ while (els.length) {
+ els[0].classList.remove('linkedSelected');
+ }
+ }
+
update() {
- console.log(this.graph)
+ // console.log(this.graph)
this.resolveOverlaps();
// see also: https://www.createwithdata.com/enter-exit-with-d3-join/
this.node = this.node.data(this.graph.nodes, d => d.id)
.join((enter) => {
- let group = enter.append("g").attr("class", getClasses);
+ let group = enter.append("g")
+ .attr("class", getClasses)
+ .attr("id", (n) => getIdForTitle(n.fulltext));
// group.call(drag(simulation));
- group.on("click", (evt, n) => selectNode(evt, n, node));
+ group.on("click", (evt, n) => this.selectNode(n));
+ group.on("mouseover", (evt, n) => {
+ // d3.select(this).classed('hover', true);
+ const links = document.getElementsByClassName('link');
+ const linkedLinks = [];
+ for (let link of links) {
+ const l = d3.select(link).datum();
+ if (n == l.target || n == l.source) {
+ link.classList.add('linkedHover');
+ // make sure it's the last element, so it's drawn on top
+ // link.parentNode.appendChild(link); .. causes gliches
+ // find related related node:
+ const otherNode = n == l.target ? l.source : l.target;
+ const otherNodeEl = document.getElementById(otherNode.id);
+ otherNodeEl.classList.add('linkedHover');
+ linkedLinks.push(l);
+ }
+ }
+ this.showTooltip(evt.target, n, linkedLinks);
+
+ });
+ group.on("mouseout", (evt, n) => {
+ this.hideTooltip();
+ const links = document.getElementsByClassName('linkedHover');
+ while (links.length) {
+ links[0].classList.remove('linkedHover');
+ }
+ });
// group.append('circle').attr("r", 5 /*this.nodeSize*/);
group.append('path')
.attr('d', (n) => {
@@ -637,10 +766,11 @@ class NodeMap {
.join(
enter => {
let group = enter.append("g")
- .attr("class", (l) => "link " + slugify(l.name));
+ .attr("class", (l) => "link " + slugify(l.name))
+ .attr("id", getLinkId);
group.append("path")
.attr("marker-end", "url(#arrowHead)")
- .attr('id', (d, i) => 'linkid_' + i)
+ .attr('id', (d, i) => 'linkpath_' + i)
.on("mouseover", function (ev, link) {
d3.select(this).classed('hover', true);
const nodes = document.getElementsByClassName('node');
@@ -654,14 +784,17 @@ class NodeMap {
}).on("mouseout", function (ev, link) {
d3.select(this).classed('hover', false);
const nodes = document.getElementsByClassName('linkHover');
- for (let n of nodes) {
- n.classList.remove('linkHover');
+ while (nodes.length) {
+ nodes[0].classList.remove('linkHover');
}
// l.classed('hover',false);
// l.target.classed('hover',false);
// l.source.classed('hover',false);
// console.log(l,'l');
- });
+ }).on("click", (ev, link) => {
+ this.selectNode(link.source);
+ })
+ ;
group.filter((l) => l.name != "City").append("text").attr("class", "labelText").text(function (l) {
return l.name;
});
@@ -795,6 +928,60 @@ class NodeMap {
redraw() {
this.update()
}
+
+ // viewBox + preserveAspectRatio can lead to a visible area that is larger than
+ // the viewBox. Try to get this
+ getVisibleBox() {
+ const svgEl = this.svg.node()
+ const vbRatio = svgEl.viewBox.baseVal.width / svgEl.viewBox.baseVal.height;
+ const wRatio = this.width / this.height;
+ if (wRatio > vbRatio) {
+ // wider
+ return {
+ width: (wRatio / vbRatio) * svgEl.viewBox.baseVal.width,
+ height: svgEl.viewBox.baseVal.height
+ }
+ } else {
+ // taller
+ return {
+ width: svgEl.viewBox.baseVal.width,
+ height: (vbRatio / wRatio) * svgEl.viewBox.baseVal.height
+ }
+ }
+ }
+
+ zoomFit(nodes, paddingPercent = 0.8, transitionDuration = 2000) {
+ // var bounds = root.node().getBBox();
+ const x0 = Math.min(...nodes.map(n => n.x - CONFIG.nodeRadius));
+ const x1 = Math.max(...nodes.map(n => n.x + CONFIG.nodeRadius));
+ const y0 = Math.min(...nodes.map(n => n.y - CONFIG.nodeRadius));
+ const y1 = Math.max(...nodes.map(n => n.y + CONFIG.nodeRadius));
+
+ const width = x1 - x0;
+ const height = y1 - y0;
+
+ const visibleBox = this.getVisibleBox();
+ const fullWidth = visibleBox.width, //2000, //this.width, TODO: use viewbox now, but consider the overflowing of the vbox
+ fullHeight = visibleBox.height; //2000; //this.height;
+ const viewBox = this.svg.node().viewBox.baseVal;
+ const midX = x0 + width / 2,
+ midY = y0 + height / 2;
+ if (width == 0 || height == 0) return; // nothing to fit
+ let scale = paddingPercent / Math.max(width / fullWidth, height / fullHeight);
+ scale = Math.min(CONFIG.zoom.scale_max, scale);
+ const translate = [fullWidth / 2 - midX, fullHeight / 2 - midY];
+ const transform = d3.zoomIdentity.translate(
+ -midX * scale + viewBox.width / 2,
+ -midY * scale + viewBox.height / 2)
+ .scale(scale);
+ // console.log("zoomFit", x0, x1, y0, y1, translate, scale, transform);
+ this.svg
+ .transition()
+ .duration(transitionDuration || 0) // milliseconds
+ .call(this.zoom.transform, transform);
+ // .call(this.zoom.translateTo, midX, midY)
+ // .call(this.zoom.scaleTo, scale);
+ }
}
class AlluvialMap {
@@ -838,7 +1025,7 @@ class AlluvialMap {
.nodeWidth(40) // height
.nodePadding(10)
.extent([[1, 5], [2000 - 1, 2000 - 5]]);
- console.log(this.graph);
+ // console.log(this.graph);
let s = this.sankey({
nodes: this.graph.nodes.map(d => Object.assign({}, d)),
links: this.graph.links.map(d => Object.assign({}, d))
@@ -938,16 +1125,27 @@ class AlluvialMap {
}
+titleIdMap = {};
+function getIdForTitle(title) {
+ if (!titleIdMap.hasOwnProperty(title)) {
+ titleIdMap[title] = slugify(title) + `-${Object.keys(titleIdMap).length}`
+ }
+ return titleIdMap[title];
+}
+
JsonToGraph = function (data) {
let nodes = [];
let links = [];
let smwBugFixLocationMaps = {};
console.log(data)
+ let i = 0;
+ let linkI = 0;
for (const node_id in data.results) {
if (Object.hasOwnProperty.call(data.results, node_id)) {
+ i++;
let node = data.results[node_id];
- node.id = node.fulltext; //node_id;
+ node.id = getIdForTitle(node.fulltext); //node_id;
nodes.push(node);
// console.log(node_id, node);
@@ -967,9 +1165,9 @@ JsonToGraph = function (data) {
}
for (const target_node of node.printouts[prop]) {
links.push({
- "source": node_id,
- "target": target_node.fulltext,
- "name": prop
+ "source": node.id,
+ "target": getIdForTitle(target_node.fulltext),
+ "name": prop,
})
}
}
@@ -998,14 +1196,18 @@ JsonToGraph = function (data) {
console.debug(`Fixed location for ${fixes} nodes`);
- console.log(links.length);
-
nodeMap = Object.fromEntries(nodes.map(d => [d['id'], d]));
links = links.filter(l => nodeMap[l.source] && nodeMap[l.target]).map(l => {
l.source = nodeMap[l.source];
l.target = nodeMap[l.target];
+ l.nr = linkI++;
return l;
- });
+ }).filter((link, index, self) =>
+ // remove incidental duplicates
+ index === self.findIndex((l) => (
+ l.source.id === link.source.id && l.target.id === link.target.id && l.name === link.name
+ ))
+ );
@@ -1318,9 +1520,9 @@ class Store {
let inputEl = document.createElement('input')
let textEl = document.createElement('span');
let svg = d3.select(labelEl).append('svg')
- .attr("viewBox", [-12,-12,24,24]);
+ .attr("viewBox", [-12, -12, 24, 24]);
svg.append('g')
- .attr("class", "node "+ f)
+ .attr("class", "node " + f)
.append('path')
.attr('d', getSymbolForCategories(f)());
inputEl.type = "checkbox";
diff --git a/www/index.html b/www/index.html
index 8b80336..37fcc94 100644
--- a/www/index.html
+++ b/www/index.html
@@ -8,6 +8,7 @@
+
@@ -31,6 +32,12 @@
+
+