Published
Edited
Jan 26, 2021
3 forks
7 stars
Insert cell
Insert cell
index = 0
Insert cell
data = grouped[index]
Insert cell
chart = {
const svg = d3.create("svg")
.attr("class", "svg")
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.on("click", reset);
const bg = svg.append("g")
.attr("class", "background");
svg.selectAll(".g-bin")
.data(data.bins)
.join("g")
.attr("class", (d) => `g-bin g-bin-${d.month}`)
.selectAll("g")
.data((d) => d.vals.map((f) => {
const e = f;
e.point = d3.pointRadial(x(d.month), y(e.party));
return e;
}))
.join("g")
.call(g => g.append("circle")
.attr("class", "circle-outer")
.attr("cx", (d) => d.point[0])
.attr("cy", (d) => d.point[1])
.attr("r", (d) => d.count === 0 ? 0 : circleRadius)
.attr("fill", (d) => lightColor(d.party)))
.on("mouseover", onMouseOver)
.call(g => g.append("circle")
.attr("class", "circle-inner")
.attr("cx", (d) => d.point[0])
.attr("cy", (d) => d.point[1])
.attr("r", (d) => d.count === 0 ? 0 : circleScale(d.count))
.attr("fill", (d) => color(d.party))
.style("pointer-events", "none")
)
bg.append("g")
.attr("class", "grid grid-x")
.call(xGrid);
bg.append("g")
.attr("class", "axis axis-x")
.call(xAxis);
bg.append("g")
.attr("class", "g-info")
.call(infoText);
return svg.node();
}
Insert cell
reset = function(_, d) {
d3.select(".text-info").attr("opacity", 0);
d3.selectAll(`.g-bin circle`).attr("fill-opacity", 1);
d3.selectAll(".grid-x circle").attr("opacity", 1);
d3.selectAll(`.x-tick--line-minor`).attr("opacity", 0);
d3.selectAll('.x-tick--label').style("font-weight", "normal");
d3.selectAll(`.x-tick--label-minor`).attr("opacity", 0);
d3.selectAll(`.x-tick--line`)
.attr("d", (d, i) => `
M${d3.pointRadial(x(techFormat(d.date)), outerRadius + d.offset + (i === 0 ? 30 : 0))}
L${d3.pointRadial(x(techFormat(d.date)), innerRadius - 20)}`);
d3.selectAll(".x-tick--arc-inner").attr("opacity", 0);
d3.select(".g-inner").remove();
}
Insert cell
onMouseOver = function(_, d) {
reset(_, d);
d3.select(".text-info").attr("opacity", 1);
d3.selectAll(`.g-bin:not(.g-bin-${d.month}) circle`).attr("fill-opacity", 0.4);
d3.selectAll(".grid-x circle").attr("opacity", 0.4);
d3.select(`.x-tick--line-${d.month}`)
.attr("d", d => `
M${d3.pointRadial(x(techFormat(d.date)), outerRadius + d.offset + (d.isFirst ? 30 : 0))}
L${d3.pointRadial(x(techFormat(d.date)), innerRadius - 40)}`)
.attr("opacity", 1);
d3.select(`.x-tick--label-${d.month}`)
.attr("opacity", 1)
.style("font-weight", "bold");
d3.select(`.x-tick--arc-inner-${d.month}`).attr("opacity", 1);
const date = d3.timeParse("%Y-%m")(d.month);
const requests = rawData
.filter((e) => e.ministry === data.ministry
&& e.date.getYear() === date.getYear()
&& e.date.getMonth() === date.getMonth())
.map((e) => {
const party = e.parties[0];
const cluster = sortedParties.includes(party) ? sortedParties.findIndex((p) => p === party) : nParties;
const focusX = 110 * Math.cos(cluster / nParties * Math.PI * 2) + Math.random() * 5;
const focusY = 110 * Math.sin(cluster / nParties * Math.PI * 2) + Math.random() * 5;
return { ...e, ...{ cluster, party, focusX, focusY, x: focusX, y: focusY } };
});
const g = d3.select(".svg")
.append("g")
.attr("class", "g-inner");
const gQuestion = g.append("g")
.attr("class", "g-questions")
.selectAll("g")
.data(requests)
.join("g");
const circle = gQuestion.append("circle")
.attr("r", questionRadius)
.attr("stroke", d => color(d.party))
.attr("stroke-width", 2)
.attr("opacity", 0)
.attr("fill", d => lightColor(d.party));
const text = gQuestion.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", d => color(d.party))
.style("font-size", '30px')
.style("font-weight", "bold")
.text("?")
const simulation = d3.forceSimulation()
// .force("collide", d3.forceCollide().radius(questionRadius + 2 + 1).strength(0.8)) //Original collide function
// Instead use the custom collide function
.force("collide", forceClusterCollision()
.radius(questionRadius + 2 + 1)
.strength(0.8)
.clusterPadding(10) //new setting - important, the cluster id of the data has to be named "cluster"
)
.force("x", d3.forceX().x(d => d.focusX).strength(0.2))
.force("y", d3.forceY().y(d => d.focusY).strength(0.2));
simulation.nodes(requests).on("tick", ticked);
function ticked(event) {
circle
.attr("cx", d => d.x)
.attr("cy", d => d.y);
text
.attr("x", d => d.x)
.attr("y", d => d.y);
}
}
Insert cell
infoText = g => g
.call(g => g.append("path")
.attr("id", "path-info")
.attr("stroke", "none")
.attr("fill", "none")
.attr('d', (d) => `
M${d3.pointRadial(-0.3, innerRadius - 30)}
A${innerRadius - 30},${innerRadius - 30} 0,0,1 ${d3.pointRadial(0.5, innerRadius - 30)}
`))
.call(g => g.append("text")
.attr("class", "text-info")
.attr("fill", "#000")
.style("font-size", "0.8em")
.style("font-style", "italic")
.append("textPath")
.attr("xlink:href", "#path-info")
.text("Click anywhere to reset"));
Insert cell
xGrid = g => g
.attr("fill", "transparent")
.call(g => g.selectAll("circle")
.data(sortedParties)
.join("circle")
.attr("r", (party) => y(party))
.attr("stroke", (party) => lightColor(party)))
Insert cell
months.slice(0, -1)
Insert cell
xAxis = g => g
.call(g => g.selectAll("g")
.data(months.slice(0, -1).map((date, i) => ({
date,
month: techFormat(date),
isMajor: date.getMonth() % 6 === 0,
isFirst: i === 0,
offset: date.getMonth() === 0 ? 30 : 10,
})))
.join("g")
.attr("font-size", 12)
.call(g => g.append("path")
.attr("class", (d, i) => `x-tick--line x-tick--line-${d.isMajor || i === 0 ? 'major' : 'minor'} x-tick--line-${techFormat(d.date)}`)
.attr("stroke", "#000")
.attr("stroke-opacity", 0.2)
.attr("stroke-dasharray", "2 4")
.attr("opacity", (d, i) => i === 0 || +d.isMajor)
.attr("d", (d, i) => `
M${d3.pointRadial(x(d.month), outerRadius + d.offset + (i === 0 ? 30 : 0))}
L${d3.pointRadial(x(d.month), innerRadius - 20)}
`))
.call(g => g.append("path")
.attr("id", (_, i) => `x-tick--text-path-${i}`)
.attr("stroke", "none")
.attr("fill", "none")
.attr('d', (d) => `
M${d3.pointRadial(x(d.month), outerRadius + d.offset - 8)}
A${outerRadius + d.offset - 8},${outerRadius + d.offset - 8} 0,0,1 ${d3.pointRadial(x(techFormat(d3.timeMonth.offset(d.date, 2))), outerRadius + d.offset - 8)}
`))
.call(g => g.append("text")
.attr("class", d => `x-tick--label x-tick--label-${d.isMajor ? 'major' : 'minor'} x-tick--label-${techFormat(d.date)}`)
.attr("opacity", d => +d.isMajor)
.append("textPath")
.attr("startOffset", 5)
.attr("xlink:href", (_, i) => `#x-tick--text-path-${i}`)
.text(d => d.date.getMonth() === 0 ? formatTime(d.date) : formatMonth(d.date)))
.call(g => g.append("path")
.attr("class", d => `x-tick--arc-inner x-tick--arc-inner-${d.month}`)
.attr("fill", "transparent")
.attr("stroke", "#000")
.attr("stroke-opacity", 0.2)
.attr("stroke-dasharray", "2 4")
.attr("opacity", 0)
.attr('d', (d) => {
let leftDate = d3.timeMonth.offset(d.date, -8);
if (!months.map(techFormat).includes(techFormat(leftDate))) {
const offset = d3.timeMonth.count(leftDate, months[0]);
leftDate = months[months.length - offset];
}
let rightDate = d3.timeMonth.offset(d.date, 8);
if (!months.map(techFormat).includes(techFormat(rightDate))) {
const offset = d3.timeMonth.count(months[months.length -1], rightDate);
rightDate = months[offset-1];
}
return `
M${d3.pointRadial(x(techFormat(leftDate)), innerRadius - 40)}
A${innerRadius - 40},${innerRadius - 40} 0,0,1 ${d3.pointRadial(x(techFormat(rightDate)), innerRadius - 40)}`;
}
))
)
Insert cell
months[0]
Insert cell
{
const date = months[3];
const leftDate = d3.timeMonth.offset(date, -8);
let offset;
let corrLeftDate;
if (!months.includes(leftDate)) {
offset = d3.timeMonth.count(leftDate, months[0]);
corrLeftDate = months[months.length - offset];
}
return {date, leftDate, offset, corrLeftDate}
}
Insert cell
techFormat = d3.timeFormat("%Y-%m")
Insert cell
formatMonth = d3.timeFormat("%b")
Insert cell
formatTime = d3.timeFormat("%b %Y")
Insert cell
maxValue = d3.max(data.bins.map(({ vals }) => d3.max(vals, (d) => d.count)))
Insert cell
circleScale = d3.scaleSqrt()
.domain([1, maxValue])
.range([1, circleRadius]);
Insert cell
y = d3.scaleBand()
.domain(sortedParties)
.range([innerRadius, outerRadius])
Insert cell
x = d3.scalePoint()
.domain(mappedMonths)
.range([0, 2 * Math.PI])
Insert cell
mappedMonths = {
const mapped = months.map(techFormat);
mapped.push(techFormat(d3.timeMonth.offset(months[months.length - 1], 1)));
return mapped
}
Insert cell
grouped = d3
.groups(rawData.filter((d) => d.date >= dates[0] && d.date <= dates[1]), (d) => d.ministry)
.sort((a, b) => d3.descending(a[1].length, b[1].length))
.map(([ministry, values]) => ({
ministry,
bins: d3
.groups(values, (d) => techFormat(d.date))
.map(([key, vals]) => {
vals = d3.rollups(vals, v => v.length, d => d.parties[0])
.map(([party, count]) => ({ party, count, month: key }))
.sort((a, b) => d3.descending(a.count, b.count)
|| d3.ascending(
sortedParties.findIndex((p) => p === a.party),
sortedParties.findIndex((p) => p === b.party)));
const missingParties = d3.difference(parties, vals.map((f) => f.party));
return {
month: key,
vals: vals.concat(Array.from(missingParties).map((m) => ({ party: m, count: 0, month: key })))
}
})
}))
Insert cell
nParties = sortedParties.length
Insert cell
sortedParties = Array.from(partyCount)
.map(([party, count]) => ({ party, count }))
.sort((a, b) => d3.descending(a.count, b.count))
.map(({ party }) => party)
Insert cell
partyCount = d3.rollup(rawData, (v) => v.length, (d) => d.parties[0])
Insert cell
parties = new Set(rawData.flatMap((d) => d.parties))
Insert cell
months = d3.timeMonths(dates[0], dates[1])
Insert cell
dates = ["1/5/2011", "30/4/2016"].map(d3.timeParse("%d/%m/%Y"))
Insert cell
rawData = {
const parseTime = d3.timeParse("%Y-%m-%d");
return d3.csvParse(await FileAttachment("data_v0.1_bawu15.csv").text(), d => ({
body: d.body,
term: d.legislative_term,
title: d.title,
type: d.interpellation_type,
date: parseTime(d.published_at),
parties: d.parties.split(";"),
ministry: d.ministries.split(";")[0],
}))
}
Insert cell
nBins = months.length
Insert cell
questionRadius = 12
Insert cell
circleRadius = Math.trunc((innerCircumference / nBins / 2) * 100) / 100
Insert cell
innerCircumference = 2 * Math.PI * innerRadius
Insert cell
outerRadius = 340
Insert cell
innerRadius = 240
Insert cell
lightColor = d3.scaleOrdinal()
.domain(parties)
.range(d3.schemePaired.filter((_, i) => i % 2 == 0))
Insert cell
color = d3.scaleOrdinal()
.domain(parties)
.range(d3.schemePaired.filter((_, i) => i % 2 == 1))
Insert cell
height = width
Insert cell
import {forceClusterCollision} from '@nbremer/custom-cluster-force-layout'
Insert cell
import {button} from "@jashkenas/inputs"
Insert cell
d3 = require('d3@6')
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