From 07503883d38eb0a731b9978e8b21f5baad308059 Mon Sep 17 00:00:00 2001 From: Ruben van de Ven Date: Thu, 29 Apr 2021 15:36:27 +0200 Subject: [PATCH] Show tooltip and select moves into view --- www/graph.css | 125 ++++++++++++++++++--- www/graph.js | 294 +++++++++++++++++++++++++++++++++++++++++-------- www/index.html | 7 ++ 3 files changed, 362 insertions(+), 64 deletions(-) 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 @@ + + \ No newline at end of file