Published
Edited
Sep 10, 2020
1 fork
Importers
10 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart ={
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);

//create background
svg.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("class", "svgBackground");
// create axis
svg.append("g")
.call(xAxis);

svg.append("g")
.call(yAxis);
// stroke paths
const path = svg.append("g")
.selectAll("path")
.data(data)
.join("path")
.attr("class", "time")
.attr("id", d => `country${d.ISO}`)
.attr("d", d => line(d.Timeseries))
.attr("fill", "none")
.attr("stroke", d => d.Fontclr)
.attr("stroke-width", 2)
.attr("stroke-dasharray", d => d.Linedsh)
.attr("visibility", "hidden");
// microtext that goes on the stroke paths
const microtext = svg.append("g")
.selectAll("textPath")
.data(data)
.join("text")
.attr("font-size", d => d.fontSize)
.attr("font-family", "Roboto")
.attr("dy", 3)
.attr("font-style", d => d.Fontita)
.attr("font-weight", d => d.Fontwgt)
.append("textPath")
.attr("href", d => `#country${d.ISO}`)
.attr("fill", d => d.Fontclr)
.text(d => d.microstring);
// text labels - force is used on these
const labels = svg.append("g")
.selectAll("text.label")
.data(data)
.join("text")
.attr("class", "label")
.attr("x", width - margin.right + 30)
.attr("y", d => unempScale(d.Timevalue.slice(-1)[0]))
.attr("font-size", 16)
.attr("font-family", "Roboto")
.attr("font-weight", "bold")
.attr("fill", d => d.Fontclr)
.text(d => `${d3.format(".1f")(d.Timevalue.slice(-1)[0])} ${d.Country}`);
// leader lines to text labels
const links = svg.append("g")
.selectAll("line")
.data(data)
.join("line")
.attr("x1", width - margin.right + 10)
.attr("x2", width - margin.right + 25)
.attr("y1", d => unempScale(d.Timevalue.slice(-1)[0]))
.attr("y2", d => unempScale(d.Timevalue.slice(-1)[0]))
.attr("stroke-width", 2 )
.attr("stroke-dasharray", function(d) { return d.Linedsh; })
.attr("stroke", function(d,i) { return d.Fontclr; });

// mousover to highlight dataseries
microtext.on('mouseover', mouseover)
microtext.on('mouseout', mouseout)
labels.on('mouseover', mouseover)
labels.on('mouseout', mouseout)
path.on('mouseover', mouseover)
path.on('mouseout', mouseout)
yield svg.node();
// need this code to run AFTER YIELDING. Collision detection of text depends on the element width and height; and getBBox() only works if the text elements have been returned as a DOM element. So YIELD first to ensure getBBox() will grab the width and height THEN run collision on the elements.
await d3.selectAll("text.label").each(function(d){
const box = this.getBBox()
d.width = box.width
d.height = box.height
})

// force simulation for text lables and leader lines
const simulation = d3.forceSimulation(data)
.force("center", d3.forceY(d => unempScale(d.Timevalue.slice(-1)[0])))
.force("collide", customCollide());
simulation.on('tick', () => {
links.attr("y2", d => d.y - d.height / 4)
labels.attr("y", d => d.y);
});
invalidation.then(() => simulation.stop());
}
Insert cell
Insert cell
Insert cell
data = {
//base file contains names in multiple languages
const file = await FileAttachment("UnemployWfontNclr2019.json").json()
file.forEach(dataUpdate) // updates the base file to have latest OECD data
const parsed = file.filter(d => d.updated === true) // removes countries that did not receive OECD data
return parsed
}
Insert cell
oecd = (await fetch(`https://stats.oecd.org/SDMX-JSON/data/KEI/LR+LRHUTTTT..ST.Q/all?startTime=${timeframe.start}&dimensionAtObservation=timeDimensions`)).json()
Insert cell
// pulls the date values out of the OECD results
oecdTime = oecd.structure.dimensions.observation[0].values
Insert cell
// pulls countries out of the OECD results
oecdIndex = oecd.structure.dimensions.series
.filter(d => d.id === "LOCATION")[0].values
Insert cell
// pulls the data values for unemployment out of the OECD results
oecdData = Object.values(oecd.dataSets[0].series)
.reduce((total, value, index, arr) => {
const time = [];
for (const [index, d] of Object.entries(value.observations)) {
const conversion = Number(oecdTime[index].id.slice(-1)) / 4 + Number(oecdTime[index].id.slice(0,4))
time.push( {time: conversion, value: d[0] })
}
total.push(time)
return total
}, [])
Insert cell
dataUpdate = function(d){
// grab the base data and replace it with the most recent timeseries data from OECD. Timeseries that runs from now (2020 at time of writing) to 15 years ago. Notice some countries only started reporting later (eg. Columbia and Switzerland) so timeseries data will not all be the same length. If the data was not replaced, it is loading from the .json file and is out of date, remove it.
d.updated = false;
const match = d.ISO,
index = oecdIndex.findIndex(d => d.id === match);
if(index > -1){
d.Timeseries = oecdData[index];
d.Timeframe = d.Timeseries.map(d => d.time)
d.Timevalue = d.Timeseries.map(d => d.value)
d.updated = true;
}
d.visible = "hidden",
d.fontSize = 10,
d.microstring = "";
for(let i=0; i < 10; i++){
d.microstring += (d.Country
+ " -- " + d.ISO
+ " -- " + d.French
+ " -- " + d.Spanish
+ " -- " + d.Italian
+ " -- " + d.German
+ " -- " + d.Swedish
+ " -- " + d.Greek
+ " -- " + d.Russian
+ " -- " + d.Arabic
+ " -- " + d.Japanese
+ " -- ")}
}
Insert cell
// d3 function to create an svg path
line = d3.line()
.curve(d3["curveNatural"])
.x(d => timeScale(d.time))
.y(d => unempScale(d.value))
// <option>curveCardinal</option>
// <option>curveBasis</option>
// <option>curveNatural</option>
Insert cell
mouseover = function(d) {
const element = d3.select(this);
const country = element.node().__data__.ISO,
pathId = `#country${country}`,
microtext = d3.selectAll("textPath").filter(d => d.ISO == country),
label = d3.selectAll("text.label").filter(d => d.ISO == country),
path = d3.select(`path${pathId}`);
microtext.attr("fill", d => {
const diffWhite = Math.abs(hexToBright(d.Fontclr) - hexToBright("#eee")),
diffBlack = Math.abs(hexToBright(d.Fontclr) - hexToBright("#111"))
return (diffWhite > diffBlack)? "#eee":"#111";
})
label.attr("fill", "111")
path.attr("stroke-width", d => d.fontSize + 1)
.attr("stroke-dasharray", "none")
.attr("visibility", "visible");
}
Insert cell
mouseout = function(d) {
const element = d3.select(this);
const country = element.node().__data__.ISO,
pathId = `#country${country}`,
microtext = d3.selectAll("textPath").filter(d => d.ISO == country),
label = d3.selectAll("text.label").filter(d => d.ISO == country),
path = d3.select(`path${pathId}`);
microtext.attr("fill", d => d.Fontclr);
label.attr("fill", d => d.Fontclr)
path.attr("stroke-width", 2)
.attr("stroke-dasharray", d => d.Linedsh)
.attr("visibility", d => d.visible);
}
Insert cell
function hexToBright(hex) {
var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, function(m, r, g, b) {
return r + r + g + g + b + b;
});

var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
var rgb = result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
var brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000 // based on W3C approx. for rgb to bright
return brightness
}
Insert cell
// update when inputs (checkbox and number field) are changed
update = {
let microtext = d3.selectAll("textPath"),
path = d3.selectAll("path.time");
microtext.attr("visibility", (ch1 == "mtext")? "visible":"hidden")
.attr("font-size", fontSize)
.each(d => d.fontSize = fontSize)
path.attr("visibility", (ch1 == "mtext")? "hidden":"visible")
.each(d => d.visible = (ch1 == "mtext")? "hidden":"visible")
}
Insert cell
// A string of text is best aproximated by a long thin rectangular bounding box where width >> height. This makes collision detection on text difficult since rectangular collision detection is not available natively in the d3.force() libriary.
// This custom rectangular collision detection algorithm works with d3.force() and can be used on text elements. This function is based on the work by the following authors:
// https://bl.ocks.org/cmgiven/547658968d365bcc324f3e62e175709b
// https://observablehq.com/@roblallier/rectangle-collision-force
// https://observablehq.com/@ravengao/collision-detection-with-quadtree
// The basic idea is to use quadtrees to check the closest elements and check for collision. When two elements are close, check for overlap and push them apart if they sharing the same space. For a great explaination on the math in this collision detection see the notebook by @ravengao. Note that bounding boxes of the text must be computed before running the force simulation.

function customCollide() {
const alpha = 1;
let nodes;
function force(alpha) {
const quadtree = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.addAll(nodes);
for (const node of nodes){
quadtree.visit((visited, x1, y1, x2, y2) => {
let updated = false;
if (visited.data && (visited.data !== node)) {
const padding = 0;
let xDist = node.x - visited.data.x, // x distance from center to center
yDist = node.y - visited.data.y, // y distance from center to center
xSpace = padding + (node.width + visited.data.width) / 2, // min space in x needed to prevent overlap
ySpace = padding + (node.height + visited.data.height) / 2, // min space in y needed to prevent overlap
xAbs = Math.abs(xDist), // ABSOLUTE center to center
yAbs = Math.abs(yDist), // ABSOLUTE center to center

fixed = (node.fixed || visited.data.fixed), // don't care about collision if node is fixed
// variables that show the following;
length, // shortest center to center distance (hypotenuse)
xOver, // amount of space that is currently overlapping in x
yOver; // amount of space that is currently overlapping in y
// elements collide iff the distance between their centers is less than the minimum required space
if (yAbs < ySpace && fixed != true){
length = Math.sqrt(xDist * xDist + yDist * yDist); // unused
xOver = Math.abs(xAbs - xSpace); // unused
yOver = Math.abs(yAbs - ySpace);
// push apart the elements if they are ovelapping
if(node.Timevalue.slice(-1)[0] > visited.data.Timevalue.slice(-1)[0]) {
node.y -= yOver;
visited.data.y += yOver;
} else {
node.y += yOver;
visited.data.y -= yOver
}
updated = true;
}
}
return updated;
})
}
}
force.initialize = _ => nodes = _;
return force;
}
Insert cell
Insert cell
unempScale = d3.scalePow()
.exponent(1/5)
.domain(d3.extent(data.map(d => d.Timevalue).flat()))
.range([height - margin.bottom, margin.top])
Insert cell
timeScale = d3.scaleLinear()
.domain(d3.extent(data.map(d => d.Timeframe).flat()))
.range([margin.left, width - margin.right])
Insert cell
xAxis = g => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(timeScale)
.tickFormat(d3.format(".0f")))
.call(g => g.append("text")
.attr("x", (width - margin.right -margin.left) / 2)
.attr("y", 38)
.attr("class", "other")
.text("Year"))
Insert cell
yAxis = g => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisRight(unempScale)
.tickSize(width - margin.left - margin.right)
.tickFormat(d => d3.format(".0%")(d / 100)))
.call(g => g.select(".domain")
.remove())
.call(g => g.selectAll(".tick")
.attr("stroke-opacity", 0.25)
.attr("stroke-dasharray", "2,2"))
.call(g => g.selectAll(".tick text")
.attr("x", -25)
.attr("dy", -4))
.call(g => g.append("text")
.attr("x", -margin.left + 20)
.attr("y", margin.top - 20)
.attr("class", "other")
.attr("text-anchor", "start")
.text("Unemployment %"))

Insert cell
Insert cell
d3 = require("d3@5")
Insert cell
import {checkbox} from "@jashkenas/inputs"
Insert cell
import {number} from "@jashkenas/inputs"
Insert cell
date = new Date();
Insert cell
timeframe = ({ start: date.getFullYear() - years, end: date.getFullYear() })
Insert cell
// change this variable to determine how many years to look back at
years = 15
Insert cell
Insert cell
Insert cell
Insert cell
html`
<link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital@0;1&display=swap" rel="stylesheet">
<style>

.svgBackground {fill: #fffbeb;}

.title {
font: 24px "Lato", sans-serif;
fill: #263c54;
font-weight: 700;
}

.other {
font: 20px "Lato", sans-serif;
fill: #263c54;
font-weight: 400;
}
</style>`
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