Show tooltip and select moves into view

This commit is contained in:
Ruben van de Ven 2021-04-29 15:36:27 +02:00
parent 4dec76a325
commit 07503883d3
3 changed files with 362 additions and 64 deletions

View File

@ -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;
}

View File

@ -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(`<marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHead" ><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelected"><path d="M0,-3L8,0L0,3"></path></marker>
this.svg.append('defs').html(`<marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHead" ><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelected"><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelectedRelated"><path d="M0,-3L8,0L0,3"></path></marker>
<!--Sketching:-->
<defs>
<filter id="tint">
@ -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('<textPath xlink:href="#titlePath">Biometric</textPath>')
this.title.append("text")
.html('<textPath xlink:href="#titlePath">Mass Surveillance</textPath>')
.html('<textPath xlink:href="#title2Path">Mass Surveillance</textPath>')
this.title.append("text")
.attr("id", "subtitle")
.html('<textPath xlink:href="#subtitlePath">' + CONFIG.subtitle + '</textPath>')
@ -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 = `
<span class='category'>${getCategories(node)[0]}</span>
<h3>${node.fulltext}</h3>
`;
if (links.length) {
const rels = links.length === 1 ? 'relationship' : 'relationships';
this.tooltipEl.innerHTML += `
<span class='clickForMore'>Click to examine ${links.length} ${rels}</span>
`;
}
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";

View File

@ -8,6 +8,7 @@
<body>
<div id='tooltip'></div>
<div id='map'></div>
<!-- <div id='alluvial'></div> -->
@ -31,6 +32,12 @@
<script src="https://unpkg.com/d3-sankey@0"></script>
<!-- <script src="//unpkg.com/d3fc@14.0.1"></script> -->
<script src="graph.js"></script>
<script>
if(window.location.hash == '#light') {
document.body.classList.add('light');
}
</script>
</body>
</html>