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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
/* overflow: hidden; */
|
||||||
/* background: linear-gradient(to top, #040308, #AD4A28, #DD723C, #fc7001, #dcb697, #9ba5ae, #3e5879, #020b1a); */
|
/* background: linear-gradient(to top, #040308, #AD4A28, #DD723C, #fc7001, #dcb697, #9ba5ae, #3e5879, #020b1a); */
|
||||||
background:linear-gradient(to top, #414141, #99a6b8);
|
background:linear-gradient(to top, #414141, #99a6b8);
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
@ -213,3 +213,13 @@ a:hover{
|
||||||
stroke: white;
|
stroke: white;
|
||||||
fill:none;
|
fill:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#alluvial{
|
||||||
|
/* position:fixed; */
|
||||||
|
top:0;
|
||||||
|
left:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alluvial .flow_label text{
|
||||||
|
font-size: 30;
|
||||||
|
}
|
397
www/graph.js
397
www/graph.js
|
@ -35,6 +35,18 @@ const CONFIG = {
|
||||||
"geo_property_map": { // used to work around a bug in SMW
|
"geo_property_map": { // used to work around a bug in SMW
|
||||||
"City Coordinates": "City",
|
"City Coordinates": "City",
|
||||||
"Country Coordinates": "City Country",
|
"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"],
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -179,7 +191,7 @@ class NodeMap {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(!overlapping)
|
if (!overlapping)
|
||||||
el.classList.remove('overlapping');
|
el.classList.remove('overlapping');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -422,29 +434,29 @@ class NodeMap {
|
||||||
enter => {
|
enter => {
|
||||||
let group = enter.append("g").attr("class", "link");
|
let group = enter.append("g").attr("class", "link");
|
||||||
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) => 'linkid_' + 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');
|
||||||
for(let n of nodes){
|
for (let n of nodes) {
|
||||||
const d = d3.select(n).datum();
|
const d = d3.select(n).datum();
|
||||||
if(d == link.target || d == link.source){
|
if (d == link.target || d == link.source) {
|
||||||
n.classList.add('linkHover');
|
n.classList.add('linkHover');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// console.log(l);
|
||||||
// console.log(l);
|
}).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) {
|
||||||
for(let n of nodes){
|
n.classList.remove('linkHover');
|
||||||
n.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');
|
});
|
||||||
});
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
@ -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) {
|
JsonToGraph = function (data) {
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
|
@ -656,11 +809,192 @@ JsonToGraph = function (data) {
|
||||||
return { nodes, links }
|
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 {
|
class Store {
|
||||||
constructor(graph, parent) {
|
constructor(graph, parent) {
|
||||||
this.nodes = graph.nodes;
|
this.nodes = graph.nodes;
|
||||||
|
@ -768,6 +1102,9 @@ class Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var mapGraph = new NodeMap('#map')
|
||||||
|
var alluvialGraph = new AlluvialMap('#alluvial')
|
||||||
|
|
||||||
// REQUEST ATLAS & GRAPH
|
// REQUEST ATLAS & GRAPH
|
||||||
const req_data = new Request(CONFIG.dataUrl, { method: 'GET' });
|
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' });
|
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]) => {
|
.then(([data, world]) => {
|
||||||
var graph = JsonToGraph(data);
|
var graph = JsonToGraph(data);
|
||||||
var store = new Store(graph, '#filters');
|
var store = new Store(graph, '#filters');
|
||||||
|
|
||||||
mapGraph.setWorld(world);
|
mapGraph.setWorld(world);
|
||||||
mapGraph.setStore(store);
|
mapGraph.setStore(store);
|
||||||
store.render()
|
// console.log();
|
||||||
|
alluvialGraph.setData(data.results);
|
||||||
|
|
||||||
|
store.render()
|
||||||
mapGraph.render()
|
mapGraph.render()
|
||||||
|
alluvialGraph.render()
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id='map'></div>
|
<div id='map'></div>
|
||||||
|
<div id='alluvial'></div>
|
||||||
|
|
||||||
<div id="filters">
|
<div id="filters">
|
||||||
|
|
||||||
|
@ -20,7 +21,8 @@
|
||||||
<script src="https://d3js.org/topojson.v3.min.js"></script>
|
<script src="https://d3js.org/topojson.v3.min.js"></script>
|
||||||
<script src="//unpkg.com/d3-geo-zoom"></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>
|
<script src="graph.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue