forked from security_vision/semantic_graph
Sankey v0.1
This commit is contained in:
parent
1277131f0d
commit
0b6e4661cb
3 changed files with 384 additions and 31 deletions
|
@ -19,7 +19,7 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
/* overflow: hidden; */
|
||||
/* background: linear-gradient(to top, #040308, #AD4A28, #DD723C, #fc7001, #dcb697, #9ba5ae, #3e5879, #020b1a); */
|
||||
background:linear-gradient(to top, #414141, #99a6b8);
|
||||
font-family: sans-serif;
|
||||
|
@ -212,4 +212,14 @@ a:hover{
|
|||
stroke-width: 6px;
|
||||
stroke: white;
|
||||
fill:none;
|
||||
}
|
||||
|
||||
#alluvial{
|
||||
/* position:fixed; */
|
||||
top:0;
|
||||
left:0;
|
||||
}
|
||||
|
||||
#alluvial .flow_label text{
|
||||
font-size: 30;
|
||||
}
|
399
www/graph.js
399
www/graph.js
|
@ -35,6 +35,18 @@ const CONFIG = {
|
|||
"geo_property_map": { // used to work around a bug in SMW
|
||||
"City Coordinates": "City",
|
||||
"Country Coordinates": "City Country",
|
||||
},
|
||||
|
||||
"alluvial_cats": ["Deployments"],
|
||||
"alluvial_props": {
|
||||
"Country": ["Country"],
|
||||
"City": ["City"],
|
||||
// ["Deployment type"], // TODO: select this
|
||||
// ["Institution type"], // TODO: select this (local gov, etc.)
|
||||
"Dataset": ["Datasets used"],
|
||||
"Company": ["Managed by", "Provided by", "Developped by (institutions)"],
|
||||
"Tech": ["Technologies Used", "Software Deployed"],
|
||||
"Funding": ["Funded by"],
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -172,14 +184,14 @@ class NodeMap {
|
|||
box1.left > box2.right ||
|
||||
box1.bottom < box2.top ||
|
||||
box1.top > box2.bottom)
|
||||
if (overlap) {
|
||||
if (overlap) {
|
||||
// TODO: try to flip labels horizontally to see if that helps
|
||||
el.classList.add('overlapping');
|
||||
overlapping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!overlapping)
|
||||
if (!overlapping)
|
||||
el.classList.remove('overlapping');
|
||||
}
|
||||
|
||||
|
@ -422,29 +434,29 @@ class NodeMap {
|
|||
enter => {
|
||||
let group = enter.append("g").attr("class", "link");
|
||||
group.append("path")
|
||||
.attr("marker-end", "url(#arrowHead)")
|
||||
.attr('id', (d, i) => 'linkid_' + i)
|
||||
.on("mouseover", function (ev,link) {
|
||||
d3.select(this).classed('hover',true);
|
||||
const nodes = document.getElementsByClassName('node');
|
||||
for(let n of nodes){
|
||||
const d = d3.select(n).datum();
|
||||
if(d == link.target || d == link.source){
|
||||
n.classList.add('linkHover');
|
||||
.attr("marker-end", "url(#arrowHead)")
|
||||
.attr('id', (d, i) => 'linkid_' + i)
|
||||
.on("mouseover", function (ev, link) {
|
||||
d3.select(this).classed('hover', true);
|
||||
const nodes = document.getElementsByClassName('node');
|
||||
for (let n of nodes) {
|
||||
const d = d3.select(n).datum();
|
||||
if (d == link.target || d == link.source) {
|
||||
n.classList.add('linkHover');
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.log(l);
|
||||
}).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');
|
||||
}
|
||||
// l.classed('hover',false);
|
||||
// l.target.classed('hover',false);
|
||||
// l.source.classed('hover',false);
|
||||
// console.log(l,'l');
|
||||
});
|
||||
// console.log(l);
|
||||
}).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');
|
||||
}
|
||||
// l.classed('hover',false);
|
||||
// l.target.classed('hover',false);
|
||||
// l.source.classed('hover',false);
|
||||
// console.log(l,'l');
|
||||
});
|
||||
group.filter((l) => l.name != "City").append("text").attr("class", "labelText").text(function (l) {
|
||||
return l.name;
|
||||
});
|
||||
|
@ -580,7 +592,148 @@ class NodeMap {
|
|||
}
|
||||
}
|
||||
|
||||
var mapGraph = new NodeMap('#map')
|
||||
class AlluvialMap {
|
||||
constructor(parent) {
|
||||
this.root = d3.select(parent);
|
||||
this.resizeEvent = window.addEventListener('resize', this.resize.bind(this));
|
||||
}
|
||||
|
||||
setData(results) {
|
||||
this.graph = JsonToAlluvial(results);
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.width = window.innerWidth;
|
||||
this.height = window.innerHeight;
|
||||
this.vbWidth = 2000;
|
||||
this.vbHeight = 2000;
|
||||
this.svg
|
||||
.attr("viewBox", [0, 0, this.vbWidth, this.vbHeight])
|
||||
.attr("width", this.width)
|
||||
.attr("height", this.height);
|
||||
|
||||
}
|
||||
|
||||
reset() {
|
||||
console.warning("Not yet implemented");
|
||||
}
|
||||
|
||||
update() {
|
||||
// this.alluvial = this.parseGraph(this.store.graph)
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
this.svg = this.root.append('svg')
|
||||
this.resize();
|
||||
|
||||
this.sankey = d3.sankey()
|
||||
.nodeId(d => d.id)
|
||||
// .nodeAlign('justify')
|
||||
.nodeWidth(40) // height
|
||||
.nodePadding(10)
|
||||
.extent([[1, 5], [2000 - 1, 2000 - 5]]);
|
||||
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))
|
||||
});
|
||||
|
||||
this.nodes = s.nodes;
|
||||
this.links = s.links;
|
||||
|
||||
const scale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||
const color = (c) => c == "Unknown" ? "#333": scale(c);
|
||||
|
||||
|
||||
this.svg.append("g")
|
||||
.attr("stroke", "#000")
|
||||
.selectAll("rect")
|
||||
.data(this.nodes)
|
||||
.join("rect")
|
||||
.attr("x", d => d.y0)
|
||||
.attr("y", d => d.x0)
|
||||
.attr("height", d => d.x1 - d.x0)
|
||||
.attr("width", d => d.y1 - d.y0)
|
||||
// .attr("fill", d => color(d.box === undefined ? d.name : d.box))
|
||||
.attr("fill", d => color(d.name))
|
||||
// .attr("fill", 'blue')
|
||||
.append("title")
|
||||
.text(d => `${d.box}: ${d.name}\n${d.value}`);
|
||||
|
||||
const link = this.svg.append("g")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-opacity", 0.5)
|
||||
.selectAll("g")
|
||||
.data(this.links)
|
||||
.join("g")
|
||||
.style("mix-blend-mode", "multiply");
|
||||
|
||||
|
||||
|
||||
const edgeColor = 'path'; // either: path, none, input, output
|
||||
if (edgeColor === "path") {
|
||||
const gradient = link.append("linearGradient")
|
||||
.attr("id", (d,i) => {
|
||||
const id = `link-${i}`; // thanks https://talk.observablehq.com/t/how-do-i-work-with-the-d3-sankey-example/1696/3
|
||||
d.uid = `url(#${id})`;
|
||||
return id;
|
||||
})
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("y1", d => d.source.x1)
|
||||
.attr("y2", d => d.target.x0)
|
||||
.attr("x1",0)
|
||||
.attr("x2", 0);
|
||||
// .attr("y1", "0%")
|
||||
// .attr("y2", "100%")
|
||||
// .attr("x1", "0%")
|
||||
// .attr("x2", "0%");
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", d => color(d.source.name));
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", d => color(d.target.name));
|
||||
}
|
||||
|
||||
link.append("path")
|
||||
// .attr("d", d3.sankeyLinkHorizontal())
|
||||
.attr("d", d3.linkVertical()
|
||||
.source(function (d) { return [d.y0, d.source.x1]; })
|
||||
.target(function (d) { return [d.y1, d.target.x0]; })
|
||||
)
|
||||
|
||||
.attr("stroke", d => edgeColor === "none" ? "#aaa"
|
||||
: edgeColor === "path" ? d.uid
|
||||
: edgeColor === "input" ? color(d.source)
|
||||
: color(d.target))
|
||||
// .attr("stroke", 'red')
|
||||
.attr("stroke-width", d => Math.max(1, d.width));
|
||||
|
||||
link.append("title")
|
||||
.text(d => `${d.source.name} → ${d.target.name}\n${d.value}`);
|
||||
|
||||
this.svg.append("g")
|
||||
.attr("font-family", "sans-serif")
|
||||
// .attr("font-size", 10)
|
||||
.attr("class", 'flow_label')
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
.join("text")
|
||||
.attr("x", d => (d.y1 + d.y0) / 2)
|
||||
.attr("y", d => d.x0 < this.width / 2 ? d.x1 + 6 : d.x0 - 6)
|
||||
.attr("dy", "0.35em")
|
||||
.attr("text-anchor", d => d.x0 < this.width / 2 ? "start" : "end")
|
||||
.text(d => d.name);
|
||||
|
||||
return this.svg.node();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
JsonToGraph = function (data) {
|
||||
let nodes = [];
|
||||
|
@ -656,11 +809,192 @@ JsonToGraph = function (data) {
|
|||
return { nodes, links }
|
||||
}
|
||||
|
||||
JsonToAlluvial = function (data) {
|
||||
let boxes = {};
|
||||
let links = [];
|
||||
|
||||
for (const box in CONFIG.alluvial_props) {
|
||||
boxes[box] = []
|
||||
}
|
||||
|
||||
const relevant_categories = CONFIG.alluvial_cats.map(c => "Category:" + c);
|
||||
|
||||
// gather boxes ('node' in alluvial)
|
||||
for (const node_id in data) {
|
||||
if (Object.hasOwnProperty.call(data, node_id)) {
|
||||
let node = data[node_id];
|
||||
|
||||
// we only want deployments
|
||||
if (!relevant_categories.includes(node.printouts["Category"][0].fulltext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const box in CONFIG.alluvial_props) {
|
||||
let has_any = false;
|
||||
for (const prop of CONFIG.alluvial_props[box]) {
|
||||
// console.log(box, prop);
|
||||
if (!node.printouts.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
for (const target_node of node.printouts[prop]) {
|
||||
boxes[box].push(target_node.fulltext)
|
||||
has_any = true;
|
||||
}
|
||||
}
|
||||
if (!has_any) {
|
||||
boxes[box].push("Unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
// reduce the items in the boxes by count. And convert to 'nodes' of the Sankey
|
||||
for (const box in CONFIG.alluvial_props) {
|
||||
boxes[box] = boxes[box].reduce(function (acc, curr) {
|
||||
if (typeof acc[curr] == 'undefined') {
|
||||
acc[curr] = 1;
|
||||
} else {
|
||||
acc[curr] += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
boxSorted = Object.keys(boxes[box]).sort(function (a, b) { return boxes[box][a] - boxes[box][b] }).reverse()
|
||||
useBox = boxSorted.splice(0, 10);
|
||||
restBox = boxSorted;
|
||||
// gather stats:
|
||||
boxes[box] = {
|
||||
'counts': boxes[box],
|
||||
'use': useBox,
|
||||
'rest': restBox,
|
||||
}
|
||||
|
||||
for (const name of useBox) {
|
||||
nodes.push({
|
||||
name,
|
||||
"id": box + "::" + name,
|
||||
box,
|
||||
"about": "" // TODO, what can we say here?
|
||||
})
|
||||
};
|
||||
|
||||
if (restBox.length) {
|
||||
nodes.push({
|
||||
"name": "Other",
|
||||
"id": box + "::" + "Other",
|
||||
box,
|
||||
"about": restBox, //some extra stats for use
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let linkMap = {};
|
||||
// another round: now, we collect the links
|
||||
for (const node_id in data) {
|
||||
if (Object.hasOwnProperty.call(data, node_id)) {
|
||||
let node = data[node_id];
|
||||
|
||||
// we only want deployments
|
||||
if (!relevant_categories.includes(node.printouts["Category"][0].fulltext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev_box = null;
|
||||
for (const box in CONFIG.alluvial_props) {
|
||||
let cur_box = [];
|
||||
let has_any = false;
|
||||
for (const prop of CONFIG.alluvial_props[box]) {
|
||||
// console.log(box, prop);
|
||||
if (!node.printouts.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
for (const target_node of node.printouts[prop]) {
|
||||
if (boxes[box].use.includes(target_node.fulltext)) {
|
||||
cur_box.push(box + "::" + target_node.fulltext)
|
||||
} else {
|
||||
cur_box.push(box + "::" + "Other")
|
||||
}
|
||||
has_any = true;
|
||||
}
|
||||
}
|
||||
if (!has_any) {
|
||||
cur_box.push(box + "::" + "Unknown")
|
||||
}
|
||||
if (prev_box !== null) {
|
||||
// TODO: links
|
||||
for (let source of prev_box) {
|
||||
for (let target of cur_box) {
|
||||
if (typeof linkMap[source] == 'undefined') {
|
||||
linkMap[source] = {};
|
||||
}
|
||||
if (typeof linkMap[source][target] == 'undefined') {
|
||||
linkMap[source][target] = 0;
|
||||
}
|
||||
linkMap[source][target] += 1 / (prev_box.length * cur_box.length) // TODO: is this right?
|
||||
// links.push({
|
||||
// source,
|
||||
// target,
|
||||
// value: 1 / (prev_box.length * cur_box.length) // TODO: is this right?
|
||||
// })
|
||||
}
|
||||
}
|
||||
}
|
||||
prev_box = cur_box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const source in linkMap) {
|
||||
for (const target in linkMap[source]) {
|
||||
links.push({
|
||||
source,
|
||||
target,
|
||||
value: linkMap[source][target]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(boxes);
|
||||
|
||||
// let fixes = 0;
|
||||
// nodes.forEach((node) => {
|
||||
// // work around SMW bug in Ask. 2/2
|
||||
// for (const idx of Object.keys(CONFIG.geo_property_map)) {
|
||||
// if (!node.printouts[idx].length) {
|
||||
// const srcProp = CONFIG.geo_property_map[idx];
|
||||
// // only retrievable if we know the name of the location (which for some reason _is_ often there)
|
||||
// if (!node.printouts[srcProp].length) {
|
||||
// continue;
|
||||
// }
|
||||
// const loc = node.printouts[srcProp][0].fulltext;
|
||||
// if (!smwBugFixLocationMaps.hasOwnProperty(loc)) {
|
||||
// continue;
|
||||
// }
|
||||
// node.printouts[idx] = smwBugFixLocationMaps[loc]
|
||||
// fixes++;
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// 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];
|
||||
// return l;
|
||||
// });
|
||||
|
||||
|
||||
|
||||
return { nodes, links, boxes }
|
||||
}
|
||||
|
||||
|
||||
var typeFilterList = [
|
||||
// 'Deployments'
|
||||
]
|
||||
class Store {
|
||||
constructor(graph, parent) {
|
||||
this.nodes = graph.nodes;
|
||||
|
@ -768,6 +1102,9 @@ class Store {
|
|||
}
|
||||
|
||||
|
||||
var mapGraph = new NodeMap('#map')
|
||||
var alluvialGraph = new AlluvialMap('#alluvial')
|
||||
|
||||
// REQUEST ATLAS & GRAPH
|
||||
const req_data = new Request(CONFIG.dataUrl, { method: 'GET' });
|
||||
const req_world = new Request('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json', { method: 'GET' });
|
||||
|
@ -778,11 +1115,15 @@ Promise.all([fetch(req_data), fetch(req_world)])
|
|||
.then(([data, world]) => {
|
||||
var graph = JsonToGraph(data);
|
||||
var store = new Store(graph, '#filters');
|
||||
|
||||
mapGraph.setWorld(world);
|
||||
mapGraph.setStore(store);
|
||||
store.render()
|
||||
// console.log();
|
||||
alluvialGraph.setData(data.results);
|
||||
|
||||
store.render()
|
||||
mapGraph.render()
|
||||
alluvialGraph.render()
|
||||
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<body>
|
||||
|
||||
<div id='map'></div>
|
||||
<div id='alluvial'></div>
|
||||
|
||||
<div id="filters">
|
||||
|
||||
|
@ -20,7 +21,8 @@
|
|||
<script src="https://d3js.org/topojson.v3.min.js"></script>
|
||||
<script src="//unpkg.com/d3-geo-zoom"></script>
|
||||
|
||||
<script src="//unpkg.com/d3fc@14.0.1"></script>
|
||||
<script src="https://unpkg.com/d3-sankey@0"></script>
|
||||
<!-- <script src="//unpkg.com/d3fc@14.0.1"></script> -->
|
||||
<script src="graph.js"></script>
|
||||
</body>
|
||||
|
||||
|
|
Loading…
Reference in a new issue