forked from security_vision/semantic_graph
Show tooltip and select moves into view
This commit is contained in:
parent
4dec76a325
commit
07503883d3
3 changed files with 362 additions and 64 deletions
125
www/graph.css
125
www/graph.css
|
@ -17,9 +17,9 @@
|
||||||
--color9: #577590;
|
--color9: #577590;
|
||||||
--color10: #277da1;
|
--color10: #277da1;
|
||||||
--hover-color: var(--color1);
|
--hover-color: var(--color1);
|
||||||
|
--hover-related-color: #d1bce9;
|
||||||
/* --hover-color: var(darkblue); */
|
/* --hover-color: var(darkblue); */
|
||||||
--selected-color: var(--color1);
|
--selected-color: var(--color1);
|
||||||
--selected-color: var(--color1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@ -41,16 +41,20 @@ svg.dragging {
|
||||||
}
|
}
|
||||||
|
|
||||||
#arrowHead {
|
#arrowHead {
|
||||||
fill: #9df32c;
|
fill: #b0e99a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#arrowHeadSelected {
|
#arrowHeadSelected {
|
||||||
fill: var(--hover-color);;
|
fill: var(--hover-color);;
|
||||||
}
|
}
|
||||||
|
#arrowHeadSelectedRelated {
|
||||||
|
fill: var(--hover-related-color);;
|
||||||
|
}
|
||||||
|
|
||||||
svg .links line, svg .links path {
|
svg .links line, svg .links path {
|
||||||
stroke: #f3722c;
|
/* stroke: #f3722c; */
|
||||||
stroke: #9df32c;
|
/* stroke: #9df32c; */
|
||||||
|
stroke: #b0e99a;
|
||||||
stroke-width: 6;
|
stroke-width: 6;
|
||||||
fill: none;
|
fill: none;
|
||||||
transition: stroke-width 1s;
|
transition: stroke-width 1s;
|
||||||
|
@ -58,6 +62,18 @@ svg .links line, svg .links path {
|
||||||
}
|
}
|
||||||
|
|
||||||
svg .links line.hover, svg .links path.hover {
|
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: var(--hover-color);
|
||||||
stroke-width: 12;
|
stroke-width: 12;
|
||||||
marker-end: url(#arrowHeadSelected);
|
marker-end: url(#arrowHeadSelected);
|
||||||
|
@ -67,10 +83,9 @@ svg.zoomed .links line, svg.zoomed .links path {
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.zoomed .links line, svg.zoomed .links path.hover {
|
/* svg.zoomed .links line, svg.zoomed .links path.hover {
|
||||||
stroke-width: 2;
|
|
||||||
stroke-width: 4;
|
stroke-width: 4;
|
||||||
}
|
} */
|
||||||
|
|
||||||
svg .title {
|
svg .title {
|
||||||
font-size: 200;
|
font-size: 200;
|
||||||
|
@ -88,7 +103,7 @@ svg #countries .country.eu_country {
|
||||||
fill: black;
|
fill: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg #header #titlePath, svg #header #subtitlePath {
|
svg #header #titlePath, svg #header #title2Path, svg #header #subtitlePath {
|
||||||
stroke: none;
|
stroke: none;
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
@ -104,8 +119,8 @@ svg #header text {
|
||||||
}
|
}
|
||||||
|
|
||||||
svg #header text:nth-of-type(2) {
|
svg #header text:nth-of-type(2) {
|
||||||
dominant-baseline: hanging;
|
/* dominant-baseline: hanging; */
|
||||||
transform: translate(10px, 25px);
|
/* transform: translate(10px, 25px); */
|
||||||
}
|
}
|
||||||
|
|
||||||
svg #header text#subtitle {
|
svg #header text#subtitle {
|
||||||
|
@ -131,13 +146,15 @@ svg #header text#subtitle {
|
||||||
/* font-size: 16pt; */
|
/* font-size: 16pt; */
|
||||||
/*Set this in JS*/
|
/*Set this in JS*/
|
||||||
transition: font-size .4s, opacity 1s;
|
transition: font-size .4s, opacity 1s;
|
||||||
fill: white;
|
fill: white; /*also when hovering node*/
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/*prevent mouse glitches*/
|
/*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;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,26 +172,30 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
||||||
|
|
||||||
/* Whenever a connected link is hovered */
|
/* Whenever a connected link is hovered */
|
||||||
|
|
||||||
.node.linkHover circle, .node.linkHover path, label:hover .node path {
|
.node.linkHover circle, .node.linkHover path, .node.linkedHover path, label:hover .node path {
|
||||||
fill: var(--hover-color) !important;
|
fill: var(--hover-related-color) !important;
|
||||||
stroke: var(--hover-color);
|
stroke: var(--hover-related-color);
|
||||||
stroke-width: 5px;
|
stroke-width: 5px;
|
||||||
}
|
}
|
||||||
|
.node.linkedSelected path {
|
||||||
|
fill: var(--hover-related-color) !important;
|
||||||
|
/* same as linkHover/linkedHover but without border */
|
||||||
|
}
|
||||||
|
|
||||||
.node.linkHover text.nodeTitle.overlapping {
|
.node.linkHover text.nodeTitle.overlapping {
|
||||||
transition: opacity 0s;
|
transition: opacity 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node:hover circle, .node:hover path {
|
.node:hover circle, .node:hover path, .node.selected path {
|
||||||
fill: var(--hover-color) !important;
|
fill: var(--hover-color) !important;
|
||||||
stroke: var(--hover-color);
|
stroke: var(--hover-color);
|
||||||
stroke-width: 5px;
|
stroke-width: 5px;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
.node:hover text {
|
.node:hover text {
|
||||||
transition: none;
|
transition: none;
|
||||||
fill: var(--hover-color);
|
fill: var(--hover-color);
|
||||||
}
|
} */
|
||||||
|
|
||||||
.node.selected circle, .node.selected path {
|
.node.selected circle, .node.selected path {
|
||||||
fill: var(--selected-color) !important;
|
fill: var(--selected-color) !important;
|
||||||
|
@ -248,6 +269,7 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#nodeInfo h2 {
|
#nodeInfo h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -258,6 +280,45 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
||||||
height: calc(100vh - 40px - 20px - 30px);
|
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 {
|
#closeInfo {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -283,6 +344,7 @@ header {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: white;
|
background: white;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
@ -353,3 +415,30 @@ p.subtitle {
|
||||||
#alluvial .flow_label text {
|
#alluvial .flow_label text {
|
||||||
font-size: 30;
|
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;
|
||||||
|
}
|
294
www/graph.js
294
www/graph.js
|
@ -4,7 +4,7 @@ const CONFIG = {
|
||||||
'subtitle': "Connections in the European Union & beyond",
|
'subtitle': "Connections in the European Union & beyond",
|
||||||
// 'nodeSize': 8,
|
// 'nodeSize': 8,
|
||||||
'nodeRadius': 5,
|
'nodeRadius': 5,
|
||||||
'nodeRepositionPadding': 8,
|
'nodeRepositionPadding': 10,
|
||||||
'baseUrl': 'https://www.securityvision.io/wiki/index.php/',
|
'baseUrl': 'https://www.securityvision.io/wiki/index.php/',
|
||||||
'dataUrl': 'result.json',
|
'dataUrl': 'result.json',
|
||||||
'preSimulate': false, // run simulation before starting, so we don't start with lines jumping around
|
'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)"],
|
"Company": ["Managed by", "Provided by", "Developped by (institutions)"],
|
||||||
"Tech": ["Technologies Used", "Software Deployed"],
|
"Tech": ["Technologies Used", "Software Deployed"],
|
||||||
"Funding": ["Funded by"],
|
"Funding": ["Funded by"],
|
||||||
}
|
},
|
||||||
|
"zoom": {
|
||||||
|
"scale_min": .2,
|
||||||
|
"scale_max": 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
"cases": [
|
||||||
|
"Data-lab Burglary-Free Neighbourhood",
|
||||||
|
"Dragonfly Project",
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
// let width = window.innerWidth;
|
// let width = window.innerWidth;
|
||||||
|
@ -59,7 +68,7 @@ const CONFIG = {
|
||||||
|
|
||||||
|
|
||||||
function getSymbolForCategories(classes) {
|
function getSymbolForCategories(classes) {
|
||||||
if(!Array.isArray(classes)) {
|
if (!Array.isArray(classes)) {
|
||||||
classes = [classes];
|
classes = [classes];
|
||||||
}
|
}
|
||||||
if (classes.includes('Institution')) {
|
if (classes.includes('Institution')) {
|
||||||
|
@ -194,12 +203,19 @@ function getClasses(obj) {
|
||||||
return 'node ' + classes.join(' ');
|
return 'node ' + classes.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLinkId(link) {
|
||||||
|
return "link_" + link.nr;
|
||||||
|
// return "link-" + link.source.id + '-' + link.target.id + '-' + slugify(link.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class NodeMap {
|
class NodeMap {
|
||||||
constructor(parent) {
|
constructor(parent) {
|
||||||
this.root = d3.select(parent);
|
this.root = d3.select(parent);
|
||||||
this.resizeEvent = window.addEventListener('resize', this.resize.bind(this));
|
this.resizeEvent = window.addEventListener('resize', this.resize.bind(this));
|
||||||
|
this.tooltipEl = document.getElementById('tooltip');
|
||||||
|
this.selectedNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
|
@ -247,7 +263,7 @@ class NodeMap {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.svg = this.root.append('svg')
|
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:-->
|
<!--Sketching:-->
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="tint">
|
<filter id="tint">
|
||||||
|
@ -315,34 +331,40 @@ class NodeMap {
|
||||||
.attr("d", this.proj(this.borders))
|
.attr("d", this.proj(this.borders))
|
||||||
|
|
||||||
let zoomTimeout = null;
|
let zoomTimeout = null;
|
||||||
const zoom = d3.zoom().scaleExtent([0.2, 10]).on("start", () => {
|
this.zoom = d3.zoom()
|
||||||
this.svg.node().classList.add("dragging");
|
.scaleExtent([CONFIG.zoom.scale_min, CONFIG.zoom.scale_max])
|
||||||
}).on("end", () => {
|
.on("start", () => {
|
||||||
this.svg.node().classList.remove("dragging");
|
this.svg.node().classList.add("dragging");
|
||||||
}).on("zoom", ({ transform }) => {
|
}).on("end", () => {
|
||||||
this.container.attr("transform", transform);
|
this.svg.node().classList.remove("dragging");
|
||||||
const oldZoom = this.svg.classed('zoomed');
|
}).on("zoom", (evt) => {
|
||||||
const newZoom = transform.k > 2.0;
|
this.container.attr("transform", evt.transform);
|
||||||
if (zoomTimeout) {
|
const oldZoom = this.svg.classed('zoomed');
|
||||||
clearTimeout(zoomTimeout)
|
const newZoom = evt.transform.k > 2.0;
|
||||||
}
|
if (zoomTimeout) {
|
||||||
zoomTimeout = setTimeout(() => {
|
clearTimeout(zoomTimeout)
|
||||||
this.g_nodes.attr('style', `font-size:${22000 / this.height / transform.k}pt`)
|
}
|
||||||
setTimeout(() => {
|
zoomTimeout = setTimeout(() => {
|
||||||
this.calculateLabels();
|
this.g_nodes.attr('style', `font-size:${22000 / this.height / evt.transform.k}pt`)
|
||||||
}, 500);
|
setTimeout(() => {
|
||||||
}, 500);
|
this.calculateLabels();
|
||||||
if (oldZoom != newZoom) {
|
}, 500);
|
||||||
this.svg.classed('zoomed', newZoom);
|
}, 250);
|
||||||
|
if (oldZoom != newZoom) {
|
||||||
|
this.svg.classed('zoomed', newZoom);
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.title = this.container.append('g').attr('id', 'header');
|
this.title = this.container.append('g').attr('id', 'header');
|
||||||
|
|
||||||
const titleFeature = {
|
const titleFeature = {
|
||||||
"type": "LineString",
|
"type": "LineString",
|
||||||
"coordinates": []
|
"coordinates": []
|
||||||
};
|
};
|
||||||
|
const title2Feature = {
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": []
|
||||||
|
};
|
||||||
const subtitleFeature = {
|
const subtitleFeature = {
|
||||||
"type": "LineString",
|
"type": "LineString",
|
||||||
"coordinates": []
|
"coordinates": []
|
||||||
|
@ -351,12 +373,17 @@ class NodeMap {
|
||||||
// projection apparently tries to find the shortest path between two points
|
// projection apparently tries to find the shortest path between two points
|
||||||
// which is NOT following a lat/lon line on the globe
|
// which is NOT following a lat/lon line on the globe
|
||||||
titleFeature.coordinates.push([index, 52]);
|
titleFeature.coordinates.push([index, 52]);
|
||||||
|
title2Feature.coordinates.push([index, 50.5]);
|
||||||
subtitleFeature.coordinates.push([index, 49]);
|
subtitleFeature.coordinates.push([index, 49]);
|
||||||
}
|
}
|
||||||
this.title.append("path")
|
this.title.append("path")
|
||||||
.attr("id", "titlePath")
|
.attr("id", "titlePath")
|
||||||
.attr("d", this.proj(titleFeature))
|
.attr("d", this.proj(titleFeature))
|
||||||
;
|
;
|
||||||
|
this.title.append("path")
|
||||||
|
.attr("id", "title2Path")
|
||||||
|
.attr("d", this.proj(title2Feature))
|
||||||
|
;
|
||||||
this.title.append("path")
|
this.title.append("path")
|
||||||
.attr("id", "subtitlePath")
|
.attr("id", "subtitlePath")
|
||||||
.attr("d", this.proj(subtitleFeature))
|
.attr("d", this.proj(subtitleFeature))
|
||||||
|
@ -364,7 +391,7 @@ class NodeMap {
|
||||||
this.title.append("text")
|
this.title.append("text")
|
||||||
.html('<textPath xlink:href="#titlePath">Biometric</textPath>')
|
.html('<textPath xlink:href="#titlePath">Biometric</textPath>')
|
||||||
this.title.append("text")
|
this.title.append("text")
|
||||||
.html('<textPath xlink:href="#titlePath">Mass Surveillance</textPath>')
|
.html('<textPath xlink:href="#title2Path">Mass Surveillance</textPath>')
|
||||||
this.title.append("text")
|
this.title.append("text")
|
||||||
.attr("id", "subtitle")
|
.attr("id", "subtitle")
|
||||||
.html('<textPath xlink:href="#subtitlePath">' + CONFIG.subtitle + '</textPath>')
|
.html('<textPath xlink:href="#subtitlePath">' + CONFIG.subtitle + '</textPath>')
|
||||||
|
@ -448,8 +475,8 @@ class NodeMap {
|
||||||
;
|
;
|
||||||
|
|
||||||
this.svg
|
this.svg
|
||||||
.call(zoom)
|
.call(this.zoom)
|
||||||
.call(zoom.transform, d3.zoomIdentity.scale(.5, .5));
|
.call(this.zoom.transform, d3.zoomIdentity.scale(.5, .5));
|
||||||
|
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
|
@ -457,6 +484,14 @@ class NodeMap {
|
||||||
setTimeout(() => this.calculateLabels(), 1000);
|
setTimeout(() => this.calculateLabels(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetZoom() {
|
||||||
|
this.deselectNode();
|
||||||
|
this.svg
|
||||||
|
.transition()
|
||||||
|
.duration(2000) // milliseconds
|
||||||
|
.call(this.zoom.transform, d3.zoomIdentity.scale(.5, .5));
|
||||||
|
}
|
||||||
|
|
||||||
getSizeForNode(node) {
|
getSizeForNode(node) {
|
||||||
return this.nodeSize;
|
return this.nodeSize;
|
||||||
}
|
}
|
||||||
|
@ -532,17 +567,111 @@ class NodeMap {
|
||||||
console.log(`moved ${moved} nodes`);
|
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() {
|
update() {
|
||||||
console.log(this.graph)
|
// console.log(this.graph)
|
||||||
|
|
||||||
this.resolveOverlaps();
|
this.resolveOverlaps();
|
||||||
|
|
||||||
// see also: https://www.createwithdata.com/enter-exit-with-d3-join/
|
// see also: https://www.createwithdata.com/enter-exit-with-d3-join/
|
||||||
this.node = this.node.data(this.graph.nodes, d => d.id)
|
this.node = this.node.data(this.graph.nodes, d => d.id)
|
||||||
.join((enter) => {
|
.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.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('circle').attr("r", 5 /*this.nodeSize*/);
|
||||||
group.append('path')
|
group.append('path')
|
||||||
.attr('d', (n) => {
|
.attr('d', (n) => {
|
||||||
|
@ -637,10 +766,11 @@ class NodeMap {
|
||||||
.join(
|
.join(
|
||||||
enter => {
|
enter => {
|
||||||
let group = enter.append("g")
|
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")
|
group.append("path")
|
||||||
.attr("marker-end", "url(#arrowHead)")
|
.attr("marker-end", "url(#arrowHead)")
|
||||||
.attr('id', (d, i) => 'linkid_' + i)
|
.attr('id', (d, i) => 'linkpath_' + i)
|
||||||
.on("mouseover", function (ev, link) {
|
.on("mouseover", function (ev, link) {
|
||||||
d3.select(this).classed('hover', true);
|
d3.select(this).classed('hover', true);
|
||||||
const nodes = document.getElementsByClassName('node');
|
const nodes = document.getElementsByClassName('node');
|
||||||
|
@ -654,14 +784,17 @@ class NodeMap {
|
||||||
}).on("mouseout", function (ev, link) {
|
}).on("mouseout", function (ev, link) {
|
||||||
d3.select(this).classed('hover', false);
|
d3.select(this).classed('hover', false);
|
||||||
const nodes = document.getElementsByClassName('linkHover');
|
const nodes = document.getElementsByClassName('linkHover');
|
||||||
for (let n of nodes) {
|
while (nodes.length) {
|
||||||
n.classList.remove('linkHover');
|
nodes[0].classList.remove('linkHover');
|
||||||
}
|
}
|
||||||
// l.classed('hover',false);
|
// l.classed('hover',false);
|
||||||
// l.target.classed('hover',false);
|
// l.target.classed('hover',false);
|
||||||
// l.source.classed('hover',false);
|
// l.source.classed('hover',false);
|
||||||
// console.log(l,'l');
|
// 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) {
|
group.filter((l) => l.name != "City").append("text").attr("class", "labelText").text(function (l) {
|
||||||
return l.name;
|
return l.name;
|
||||||
});
|
});
|
||||||
|
@ -795,6 +928,60 @@ class NodeMap {
|
||||||
redraw() {
|
redraw() {
|
||||||
this.update()
|
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 {
|
class AlluvialMap {
|
||||||
|
@ -838,7 +1025,7 @@ class AlluvialMap {
|
||||||
.nodeWidth(40) // height
|
.nodeWidth(40) // height
|
||||||
.nodePadding(10)
|
.nodePadding(10)
|
||||||
.extent([[1, 5], [2000 - 1, 2000 - 5]]);
|
.extent([[1, 5], [2000 - 1, 2000 - 5]]);
|
||||||
console.log(this.graph);
|
// console.log(this.graph);
|
||||||
let s = this.sankey({
|
let s = this.sankey({
|
||||||
nodes: this.graph.nodes.map(d => Object.assign({}, d)),
|
nodes: this.graph.nodes.map(d => Object.assign({}, d)),
|
||||||
links: this.graph.links.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) {
|
JsonToGraph = function (data) {
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let links = [];
|
let links = [];
|
||||||
|
|
||||||
let smwBugFixLocationMaps = {};
|
let smwBugFixLocationMaps = {};
|
||||||
console.log(data)
|
console.log(data)
|
||||||
|
let i = 0;
|
||||||
|
let linkI = 0;
|
||||||
for (const node_id in data.results) {
|
for (const node_id in data.results) {
|
||||||
if (Object.hasOwnProperty.call(data.results, node_id)) {
|
if (Object.hasOwnProperty.call(data.results, node_id)) {
|
||||||
|
i++;
|
||||||
let node = data.results[node_id];
|
let node = data.results[node_id];
|
||||||
node.id = node.fulltext; //node_id;
|
node.id = getIdForTitle(node.fulltext); //node_id;
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
// console.log(node_id, node);
|
// console.log(node_id, node);
|
||||||
|
|
||||||
|
@ -967,9 +1165,9 @@ JsonToGraph = function (data) {
|
||||||
}
|
}
|
||||||
for (const target_node of node.printouts[prop]) {
|
for (const target_node of node.printouts[prop]) {
|
||||||
links.push({
|
links.push({
|
||||||
"source": node_id,
|
"source": node.id,
|
||||||
"target": target_node.fulltext,
|
"target": getIdForTitle(target_node.fulltext),
|
||||||
"name": prop
|
"name": prop,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -998,14 +1196,18 @@ JsonToGraph = function (data) {
|
||||||
|
|
||||||
console.debug(`Fixed location for ${fixes} nodes`);
|
console.debug(`Fixed location for ${fixes} nodes`);
|
||||||
|
|
||||||
console.log(links.length);
|
|
||||||
|
|
||||||
nodeMap = Object.fromEntries(nodes.map(d => [d['id'], d]));
|
nodeMap = Object.fromEntries(nodes.map(d => [d['id'], d]));
|
||||||
links = links.filter(l => nodeMap[l.source] && nodeMap[l.target]).map(l => {
|
links = links.filter(l => nodeMap[l.source] && nodeMap[l.target]).map(l => {
|
||||||
l.source = nodeMap[l.source];
|
l.source = nodeMap[l.source];
|
||||||
l.target = nodeMap[l.target];
|
l.target = nodeMap[l.target];
|
||||||
|
l.nr = linkI++;
|
||||||
return l;
|
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 inputEl = document.createElement('input')
|
||||||
let textEl = document.createElement('span');
|
let textEl = document.createElement('span');
|
||||||
let svg = d3.select(labelEl).append('svg')
|
let svg = d3.select(labelEl).append('svg')
|
||||||
.attr("viewBox", [-12,-12,24,24]);
|
.attr("viewBox", [-12, -12, 24, 24]);
|
||||||
svg.append('g')
|
svg.append('g')
|
||||||
.attr("class", "node "+ f)
|
.attr("class", "node " + f)
|
||||||
.append('path')
|
.append('path')
|
||||||
.attr('d', getSymbolForCategories(f)());
|
.attr('d', getSymbolForCategories(f)());
|
||||||
inputEl.type = "checkbox";
|
inputEl.type = "checkbox";
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div id='tooltip'></div>
|
||||||
<div id='map'></div>
|
<div id='map'></div>
|
||||||
<!-- <div id='alluvial'></div> -->
|
<!-- <div id='alluvial'></div> -->
|
||||||
|
|
||||||
|
@ -31,6 +32,12 @@
|
||||||
<script src="https://unpkg.com/d3-sankey@0"></script>
|
<script src="https://unpkg.com/d3-sankey@0"></script>
|
||||||
<!-- <script src="//unpkg.com/d3fc@14.0.1"></script> -->
|
<!-- <script src="//unpkg.com/d3fc@14.0.1"></script> -->
|
||||||
<script src="graph.js"></script>
|
<script src="graph.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if(window.location.hash == '#light') {
|
||||||
|
document.body.classList.add('light');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
Loading…
Reference in a new issue