Published
Edited
Jul 19, 2022
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
r_values = cobalt_report_exports
.filter((f) => f.key == selectYear)
.map((m) => m.values.map((a) => a.values).flat())
.flat()
.filter((f) => f.value != "")
.filter((f) => f.value != "0")
Insert cell
p_values = cobalt_partner_exports
.filter((f) => f.key == selectYear)
.map((m) => m.values.map((a) => a.values).flat())
.flat()
.filter((f) => f.value != "")
.filter((f) => f.value != "0")
Insert cell
b_values = bulk_cobalt_exports
.filter((f) => f.key == selectYear)
.map((m) => m.values.map((a) => a.values).flat())
.flat()
.filter((f) => f.value != "")
.filter((f) => f.value != "0")
Insert cell
bulk_cobalt = FileAttachment("bulk_cobalt.csv").csv()
Insert cell
bulk_cobalt_exports = d3Collect
.nest()
.key(function (d) {
return d.yr;
})
.key(function (d) {
return d.source;
})
.entries(
bulk_cobalt
.filter((f) => f.Flow == "1")
.filter((f) => f.Partner_Name != "World")
.filter((f) => f.cmdCode != "2605")
.map((m) => {
let newobj = {};
newobj.source = m.Partner_Name;
newobj.target = m.Reporter_Name;
newobj.value = m.TradeQuantity;
newobj.yr = m.Year;
return newobj;
})
)
Insert cell
import { cobalt_partner_exports } from "@lifewinning/comtrade-commodity-flow-sketches"
Insert cell
import { cobalt_report_exports } from "93f2cb19e13bcafa"
Insert cell
import { links } from "990f0ef538f52b08"
Insert cell
Insert cell
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/sankey-diagram
function SankeyChart(
{
nodes, // an iterable of node objects (typically [{id}, …]); implied by links if missing
links // an iterable of link objects (typically [{source, target}, …])
},
{
format = ",", // a function or format specifier for values in titles
align = "justify", // convenience shorthand for nodeAlign
nodeId = (d) => d.id, // given d in nodes, returns a unique identifier (string)
highlight = [], // highlight given id (must match a return value from nodeID)
iterations, // number of iterations passed to d3 sankey
nodeGroup, // given d in nodes, returns an (ordinal) value for color
nodeGroups, // an array of ordinal values representing the node groups
nodeSort = undefined, // or "null" for input order
nodeLabel, // given d in (computed) nodes, text to label the associated rect
nodeTitle = (d) => `${d.id}\n${format(d.value)}`, // given d in (computed) nodes, hover text
nodeAlign = align, // Sankey node alignment strategy: left, right, justify, center
nodeOver, // callback on mouseover on nodes
nodeOut, // callback on mouseout from a node
nodeClick, // callback on click for a node
nodeWidth = 15, // width of node rects
nodePadding = 10, // vertical separation between adjacent nodes
nodeLabelPadding = 6, // horizontal separation between node and label
nodeStroke = "currentColor", // stroke around node rects
nodeStrokeWidth, // width of stroke around node rects, in pixels
nodeStrokeOpacity, // opacity of stroke around node rects
nodeStrokeLinejoin, // line join for stroke around node rects
linkSource = ({ source }) => source, // given d in links, returns a node identifier string
linkTarget = ({ target }) => target, // given d in links, returns a node identifier string
linkValue = ({ value }) => value, // given d in links, returns the quantitative value
linkPath = sankeyLinkHorizontal(), // given d in (computed) links, returns the SVG path
linkTitle = (d) => `${d.source.id} → ${d.target.id}\n${format(d.value)}`, // given d in (computed) links
linkColor = "source-target", // source, target, source-target, or static color
linkStrokeOpacity = 0.5, // link stroke opacity
linkMixBlendMode = "multiply", // link blending mode
colors = viridis, // array of colors
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
marginTop = 1, // top margin, in pixels
marginRight = 1, // right margin, in pixels
marginBottom = 1, // bottom margin, in pixels
marginLeft = 1 // left margin, in pixels
} = {}
) {
// Convert nodeAlign from a name to a function (since d3-sankey is not part of core d3).
if (typeof nodeAlign !== "function")
nodeAlign =
{
left: d3Sankey.sankeyLeft,
right: d3Sankey.sankeyRight,
center: d3Sankey.sankeyCenter
}[nodeAlign] ?? d3Sankey.sankeyJustify;

// Compute values.
const LS = d3.map(links, linkSource).map(intern);
const LT = d3.map(links, linkTarget).map(intern);
const LV = d3.map(links, linkValue);
if (nodes === undefined)
nodes = Array.from(d3.union(LS, LT), (id) => ({ id }));
const N = d3.map(nodes, nodeId).map(intern);
const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern);

// Replace the input nodes and links with mutable objects for the simulation.
nodes = d3.map(nodes, (_, i) => ({ id: N[i] }));
links = d3.map(links, (_, i) => ({
source: LS[i],
target: LT[i],
value: LV[i]
}));

// Ignore a group-based linkColor option if no groups are specified.
if (!G && ["source", "target", "source-target"].includes(linkColor))
linkColor = "currentColor";

// Compute default domains.
if (G && nodeGroups === undefined) nodeGroups = G;

// Construct the scales.
const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors);

// Compute the Sankey layout.
const sanky = d3Sankey
.sankeyCircular()
.nodeId(({ index: i }) => N[i])
.nodeAlign(nodeAlign)
// .nodeSort(nodeSort)
.nodeWidth(nodeWidth)
.nodePadding(nodePadding)
.circularLinkGap(5)
.iterations(iterations || 6)
.extent([
[marginLeft, marginTop],
[width - marginRight, height - marginBottom]
])({ nodes, links });

// Compute titles and labels using layout nodes, so as to access aggregate values.
if (typeof format !== "function") format = d3.format(format);
const Tl =
nodeLabel === undefined
? N
: nodeLabel == null
? null
: d3.map(nodes, nodeLabel);
const Tt = nodeTitle == null ? null : d3.map(nodes, nodeTitle);
const Lt = linkTitle == null ? null : d3.map(links, linkTitle);

// A unique identifier for clip paths (to avoid conflicts).
const uid = `O-${Math.random().toString(16).slice(2)}`;

// Compute node linkages to highlight
const activeClass = "is-active";
let related = [];
if (highlight)
related = highlight.reduce((acc, id) => getRelated(id, links), []);

const zoom = d3.zoom().scaleExtent([1, 40]).on("zoom", zoomed);

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.on("click", reset)
.classed(activeClass, highlight.length > 0);

const wrapper = svg.append("g");

const link = wrapper
.append("g")
.attr("fill", "none")
.attr("stroke-opacity", linkStrokeOpacity)
.selectAll("g")
.data(sanky.links)
.join("g")
.style("mix-blend-mode", linkMixBlendMode);

const node = wrapper
.append("g")
.attr("stroke", nodeStroke)
.attr("stroke-width", nodeStrokeWidth)
.attr("stroke-opacity", nodeStrokeOpacity)
.attr("stroke-linejoin", nodeStrokeLinejoin)
.attr("style", nodeClick ? "cursor: pointer" : "")
.selectAll("rect")
.data(nodes)
.join("rect")
.classed("highlighted", (n) => highlight.includes(n.id))
.classed("related", (n, i) => related.length > 0 && related.includes(n.id))
.attr("x", (d) => d.x0)
.attr("y", (d) => d.y0)
.attr("height", (d) => d.y1 - d.y0)
.attr("width", (d) => d.x1 - d.x0);

if (G) node.attr("fill", ({ index: i }) => color(G[i]));
if (Tt) node.append("title").text(({ index: i }) => Tt[i]);

if (linkColor === "source-target")
link
.append("linearGradient")
.attr("id", (d) => `${uid}-link-${d.index}`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", (d) => d.source.x1)
.attr("x2", (d) => d.target.x0)
.call((gradient) =>
gradient
.append("stop")
.attr("offset", "0%")
.attr("stop-color", ({ source: { index: i } }) => color(G[i]))
)
.call((gradient) =>
gradient
.append("stop")
.attr("offset", "100%")
.attr("stop-color", ({ target: { index: i } }) => color(G[i]))
);

link
.classed("highlighted", (d) => {
if ([d.source.x1, d.y0, d.target.x0, d.y1].includes(NaN))
console.error("bad link targets", d, [
d.source.x1,
d.y0,
d.target.x0,
d.y1
]);
return (
highlight.length > 0 &&
(highlight.includes(d.source.id) || highlight.includes(d.target.id))
);
})
.classed(
"related",
(link) =>
related.length > 0 &&
related.includes(link.source.id) &&
related.includes(link.target.id)
)
.classed(
"connected",
(d) =>
highlight.length > 0 &&
highlight.includes(d.source.id) &&
highlight.includes(d.target.id)
)
.append("path")
.attr("d", linkPath)
.attr(
"stroke",
linkColor === "source-target"
? ({ index: i }) => `url(#${uid}-link-${i})`
: linkColor === "source"
? ({ source: { index: i } }) => color(G[i])
: linkColor === "target"
? ({ target: { index: i } }) => color(G[i])
: linkColor
)
.attr("stroke-width", ({ width }) => Math.max(2, width))
.call(
Lt
? (path) => path.append("title").text(({ index: i }) => Lt[i])
: () => {}
);

if (Tl)
wrapper
.append("g")
.attr("font-family", "sans-serif")
.selectAll("text")
.data(nodes)
.join("text")
.attr("font-size", (n) => (highlight.includes(n.id) ? 18 : 10))
.classed("highlighted", (n) => highlight.includes(n.id))

.attr("x", (d) =>
d.x0 < width / 2 ? d.x1 + nodeLabelPadding : d.x0 - nodeLabelPadding
)
.attr("y", (d) => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
.text(({ index: i }) => Tl[i]);

node
.on("mouseover", function (e) {
const nData = d3.select(this).data()[0];
// activate highlight css context

if (nodeOver) nodeOver(nData, nodes, links);
})
.on("mouseout", function (e) {
const nData = d3.select(this).data()[0];
if (nodeOut) nodeOut(nData, nodes, links);
})
.on("click", (e) => {
const nData = d3.select(e.target).data()[0];
if (nodeClick) nodeClick(nData, nodes, links);
});

//svg.call(zoom);

function getRelated(needle, links, dir = "", ids = []) {
const newSources = links
// when looking backwards, the built item needs to be in
.filter((l) => needle === l.target.id && !ids.includes(l.source.id))
.map((e) => e.source.id);
const newTargets = links
.filter((l) => needle === l.source.id && !ids.includes(l.target.id))
.map((e) => e.target.id);

ids = ids.concat([needle]);

// check in one direction
if (newSources.length !== 0 && (dir === "" || dir === "down"))
newSources.forEach((n) => {
ids = getRelated(n, links, "down", ids);
});

if (newTargets.length !== 0 && (dir === "" || dir === "up"))
newTargets.forEach((n) => {
ids = getRelated(n, links, "up", ids);
});

return ids;
}

function intern(value) {
return value !== null && typeof value === "object"
? value.valueOf()
: value;
}

function reset() {
svg
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity,
d3.zoomTransform(svg.node()).invert([width / 2, height / 2])
);
}

function clicked(event, [x, y]) {
event.stopPropagation();
svg
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(40)
.translate(-x, -y),
d3.pointer(event)
);
}

function zoomed({ transform }) {
wrapper.attr("transform", transform);
}

return Object.assign(svg.node(), {
zoomIn: () => svg.transition().call(zoom.scaleBy, 2),
zoomOut: () => svg.transition().call(zoom.scaleBy, 0.5),
zoomReset: reset,
scales: { color }
});
}
Insert cell
// Function that appends a path to selection that has sankey path data attached
// The path is formatted as dash array, and triangle paths to create arrows along the path
function appendArrows(selection, arrowLength, gapLength, arrowHeadSize) {
let totalDashArrayLength = arrowLength + gapLength;

let arrows = selection
.append("path")
.attr("d", function (d) {
return d.path;
})
.style("stroke-width", 1)
.style("stroke", "black")
.style("stroke-dasharray", arrowLength + "," + gapLength);

arrows.each(function (arrow) {
let thisPath = d3.select(this).node();
let parentG = d3.select(this.parentNode);
let pathLength = thisPath.getTotalLength();
let numberOfArrows = Math.ceil(pathLength / totalDashArrayLength);

// remove the last arrow head if it will overlap the target node
if (
(numberOfArrows - 1) * totalDashArrayLength +
(arrowLength + (arrowHeadSize + 1)) >
pathLength
) {
numberOfArrows = numberOfArrows - 1;
}

let arrowHeadData = d3.range(numberOfArrows).map(function (d, i) {
let length = i * totalDashArrayLength + arrowLength;

let point = thisPath.getPointAtLength(length);
let previousPoint = thisPath.getPointAtLength(length - 2);

let rotation = 0;

if (point.y == previousPoint.y) {
rotation = point.x < previousPoint.x ? 180 : 0;
} else if (point.x == previousPoint.x) {
rotation = point.y < previousPoint.y ? -90 : 90;
} else {
let adj = Math.abs(point.x - previousPoint.x);
let opp = Math.abs(point.y - previousPoint.y);
let angle = Math.atan(opp / adj) * (180 / Math.PI);
if (point.x < previousPoint.x) {
angle = angle + (90 - angle) * 2;
}
if (point.y < previousPoint.y) {
rotation = -angle;
} else {
rotation = angle;
}
}

return { x: point.x, y: point.y, rotation: rotation };
});

let arrowHeads = parentG
.selectAll(".arrow-heads")
.data(arrowHeadData)
.enter()
.append("path")
.attr("d", function (d) {
return (
"M" +
d.x +
"," +
(d.y - arrowHeadSize / 2) +
" " +
"L" +
(d.x + arrowHeadSize) +
"," +
d.y +
" " +
"L" +
d.x +
"," +
(d.y + arrowHeadSize / 2)
);
})
.attr("class", "arrow-head")
.attr("transform", function (d) {
return "rotate(" + d.rotation + "," + d.x + "," + d.y + ")";
})
.style("fill", "black");
});
}
Insert cell
Insert cell
d3Sankey = require.alias({
"d3-array": d3,
"d3-shape": d3,
"d3-sankey": "d3-sankey@0.12.3/dist/d3-sankey.min.js",
"d3-sankey-circular": import(
"https://cdn.skypack.dev/d3-sankey-circular@0.34.0?min"
)
})("d3-sankey-circular")
Insert cell
d3Collect = require("d3-collection@latest")
Insert cell
sankeyLinkHorizontal = (
await import("https://cdn.skypack.dev/d3-sankey@0.12.3?min")
).sankeyLinkHorizontal
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more