Public
Edited
Jan 23, 2024
Insert cell
Insert cell
d3 = require("d3@7", "d3-sankey@0.12")
Insert cell
Insert cell
Insert cell
Insert cell
linkData = FileAttachment("scen_1_2021_links@3.json").json()

Insert cell
viewof startKneeOffset = Inputs.range([0, 100], {label: "Start knee", step: 1})
Insert cell
viewof endKneeOffset = Inputs.range([13, 250], {label: "End knee", step: 1})
Insert cell
viewof r = Inputs.range([0, 50], {label: "Smoothing", step: 1})
Insert cell
function straightLine(d, i) {
const startKnee = startKneeOffset * (3*d.target.layer-d.source.layer)
const endKnee = endKneeOffset - d.y0 / 4

// Straight version:
return [
'M', d.source.x1, d.y0,
'h', startKnee ,
'C',
// Two control points for the curve
d.source.x1 + startKnee+ r, d.y0,
d.target.x0 - endKnee - r, d.y1,
// Curve endpoint
d.target.x0 - endKnee, d.y1,
'h', endKnee,
].join(' ')
}
Insert cell
chart = SankeyChart({
nodes: nodeData.data,
links: linkData.data
}, {
nodeGroup: d => d.id.split(/\W/)[0],
format: (f => d => `${f(d)} Quads`)(d3.format(",.1~f")),
width: 1200,
height: 500
});

Insert cell
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
nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)
nodeGroup, // given d in nodes, returns an (ordinal) value for color
nodeGroups, // an array of ordinal values representing the node groups
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 = d3Sankey.sankeyLeft, // Sankey node alignment strategy: left, right, justify, center
nodeWidth = 50, // width of node rects
nodePadding = 10, // vertical separation between adjacent nodes
nodeLabelPadding = 5, // 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 = straightLine, // given d in (computed) links, returns the SVG path # working on this
linkTitle = d => `${d.source.id} → ${d.target.id}\n${format(d.value)}`, // given d in (computed) links
linkColor = 'layered', //"#aaa", // source, target, source-target, or static color
linkStrokeOpacity = 0.5, // link stroke opacity
linkMixBlendMode = "multiply", // link blending mode
colors = colorScale, // array of colors
width = 1000, // outer width, in pixels
height = 400, // outer height, in pixels
marginTop = 5, // top margin, in pixels
marginRight = 1, // right margin, in pixels
marginBottom = 5, // bottom margin, in pixels
marginLeft = 1, // left margin, in pixels
} = {}) {
// 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]}));

// 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 sankey = d3Sankey
.sankey()
.nodeId(({index: i}) => N[i])
.nodeAlign(nodeAlign)
.nodeWidth(nodeWidth)
.nodePadding(nodePadding)
.nodeSort(null)
.iterations(10000)
.linkSort((a, b) => {
const targetIndex = target => nodeData.data.findIndex(node => node.id === target)

if (a.source === b.source) {
return targetIndex(a.target.id) - targetIndex(b.target.id)
}
if (a.target === b.target) {
return targetIndex(a.source.id) - targetIndex(b.source.id)
}
})
.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)}`;

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;");

const node = svg.append("g")
.attr("stroke", nodeStroke)
.attr("stroke-width", nodeStrokeWidth)
.attr("stroke-opacity", nodeStrokeOpacity)
.attr("stroke-linejoin", nodeStrokeLinejoin)
.selectAll("rect")
.data(nodes)
.join("rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0)//,30)
//.attr("height", d => Math.max(d.y1 - d.y0,30))
.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]);

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

var totalNodes = 16;
link.append("path")
.attr("d", linkPath)
.attr("stroke", function(d) {
if (linkColor === "layered") {
// Use the color of the target node for the last two nodes
if (d.target.index >= totalNodes - 2) {
return color(G[d.target.index]);
} else {
// Use the color of the source node for other nodes
return color(G[d.source.index]);
}
} else if (linkColor === "source-target") {
return `url(#${uid}-link-${d.index})`;
} else if (linkColor === "source") {
return color(G[d.source.index]);
} else if (linkColor === "target") {
return color(G[d.target.index]);
} else {
// Handle other cases or default behavior here
return linkColor;
}
})
.attr("stroke-width", ({width}) => Math.max(1, width))
.attr('stroke-linejoin', 'bevel')
.call(Lt ? path => path.append("title").text(({index: i}) => Lt[i]) : () => {});

if (Tl) {
svg.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.selectAll("text")
.data(nodes)
.join("text")
.attr("x", d => (d.x0 + d.x1) / 2)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.text(({ index: i }) => Tl[i])
.selectAll("tspan")
.data(({ index: i }) => Tl[i].split(/\s+/).slice(1))
.join("tspan")
.attr("x", d => (d.x0 + d.x1) / 2) // Center each line horizontally
.attr("dy", "1.1em")
.text(d => d);
}
function intern(value) {
return value !== null && typeof value === "object" ? value.valueOf() : value;
}

// Relative to container/ node rect
node
.append("text")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("x", (d) => (d.x0 < width / 2 ? 6 + (d.x1 - d.x0) : -6)) // +/- 6 pixels relative to container
.attr("y", (d) => (d.y1 - d.y0) / 2) // middle of node
.attr("dy", "0.35em")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
.text((d) => d.name);
// d3V6 events: https://observablehq.com/@d3/d3v6-migration-guide#events
node
.attr("cursor", "move")
.call(d3.drag().on("start", dragStart).on("drag", dragMove));
function dragStart(event, d) {
d.__x = event.x;
d.__y = event.y;
d.__x0 = d.x0;
d.__y0 = d.y0;
d.__x1 = d.x1;
d.__y1 = d.y1;
} //dragStart

function dragMove(event, d) {
d3.select(this).attr("transform", function (d) {
const dx = event.x - d.__x;
const dy = event.y - d.__y;
d.x0 = d.__x0 + dx;
d.x1 = d.__x1 + dx;
d.y0 = d.__y0 + dy;
d.y1 = d.__y1 + dy;

if (d.x0 < 0) {
d.x0 = 0;
d.x1 = nodeWidth;
} // if

if (d.x1 > width) {
d.x0 = width - nodeWidth;
d.x1 = width;
} // if

if (d.y0 < 0) {
d.y0 = 0;
d.y1 = d.__y1 - d.__y0;
} // if

if (d.y1 > height) {
d.y0 = height - (d.__y1 - d.__y0);
d.y1 = height;
} // if

return `translate(${d.x0}, ${d.y0})`;
}); //.attr('transform', function (d) {

// https://github.com/d3/d3-sankey#sankey_update
sankey.update({ nodes, links });
link.selectAll(".link").attr("d", linkPath);
}
return Object.assign(svg.node(), {scales: {color}});
}
Insert cell
function updateData(newData) {
// Update the internal data
this.nodes = newData.nodes;
this.links = newData.links;

// Recalculate positions and sizes based on the updated data
// (Your logic for updating the chart based on new data goes here)

// Assuming you have a render method to render or update the chart
this.render();
}
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