Oslo as 100 persons
- Data visualisation
- Scrollytelling
- D3.js
Challenge
Translating demographic data into a visual story
Concept
This visualisation was part of a proof of concept and as an example of data driven storytelling that I did at City of Oslo. This template moves little circles – each representing one percent of the population – into groups with labels as the user scrolls the page.
Discovery
I wanted to explore scrollytelling (“scrolling” + “story telling”) for this concept. Scrollytelling is (as far as I’m aware) a relatively recent invention popularised by data journalism departments in certain news corporations.
A few examples of scrollytelling:
- Where can North Korea’s missile reach? by ABC News
- If the moon were only 1 pixel by Josh Worth
- 2014 was the hottest year on record by Bloomberg
If you’re interested in seeing more of this kind of visualisations, take a look at this list of great scrollytelling examples from Jim Vallandingham.
Process
In order to draw the initial “100” number on the top, I defined a simple grid making sure that there was exactly 100 “pixels” making up this number. Took me a few attempts to get it right, but I think it turned out pretty good to be honest.
const grid = [
[0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0],
]
Depending on how many groups
const layouts = [
[
// one group
{ x: 0.5, y: 0.5 },
],
[
// two groups
{ x: 0.25, y: 0.5 },
{ x: 0.75, y: 0.5 },
],
[
// three groups
{ x: 0.25, y: 0.66 },
{ x: 0.5, y: 0.33 },
{ x: 0.75, y: 0.66 },
],
[
// four groups
{ x: 0.5, y: 0.25 },
{ x: 0.25, y: 0.5 },
{ x: 0.75, y: 0.5 },
{ x: 0.5, y: 0.75 },
],
[
// five groups
{ x: 0.25, y: 0.25 },
{ x: 0.75, y: 0.25 },
{ x: 0.5, y: 0.5 },
{ x: 0.25, y: 0.75 },
{ x: 0.75, y: 0.75 },
],
[
// six groups
{ x: 0.25, y: 0.33 },
{ x: 0.5, y: 0.25 },
{ x: 0.75, y: 0.33 },
{ x: 0.25, y: 0.66 },
{ x: 0.5, y: 0.75 },
{ x: 0.75, y: 0.66 },
],
[
// seven groups
{ x: 0.33, y: 0.25 },
{ x: 0.66, y: 0.25 },
{ x: 0.25, y: 0.5 },
{ x: 0.5, y: 0.5 },
{ x: 0.75, y: 0.5 },
{ x: 0.33, y: 0.75 },
{ x: 0.66, y: 0.75 },
],
]
What I learned
Code
script.js
import * as d3 from 'd3'
import * as config from './config'
const margin = { top: 0, left: 0, right: 0, bottom: 0 }
const [width, height] = [360, 360]
const radius = 3.5
const labelOffset = 40
export class Viz {
constructor(el) {
this.svg = d3.select(el)
this.svg.attr('width', width + margin.right + margin.left).attr('height', height + margin.top + margin.bottom)
this.canvas = this.svg
.append('g')
.attr('class', 'canvas')
.attr('transform', `translate(${margin.left}, ${margin.top})`)
this.nodeContainer = this.canvas.append('g').attr('class', 'nodes')
this.nodes = this.nodeContainer.selectAll('circle').data(config.data).join('circle')
this.nodes.call(initialCircleStyles)
this.legends = this.canvas.append('g').attr('class', 'legend')
this.simulation = d3
.forceSimulation(config.data)
.force('center', d3.forceCenter(width / 2, height / 2))
.on('tick', () => {
this.nodes.attr('cx', (d) => d.x).attr('cy', (d) => d.y)
})
.force('charge', d3.forceManyBody().strength(-0.5))
.force('center', null)
.force(
'collision',
d3
.forceCollide()
.strength(1)
.radius(radius + 1)
.iterations(10)
)
.velocityDecay(0.1)
.alphaDecay(0.002)
}
render(view) {
this.view = view
this.nodes.transition().attr('opacity', 1).attr('r', radius)
this.findNodePositions()
this.drawLegend()
this.startSimulation()
}
drawLegend() {
let viewData
if (this.view === 'default' || !this.view) {
this.legends.attr('opacity', 0)
return
}
this.legends.attr('opacity', this.view !== 'default' ? 1 : 0)
viewData = config.sectionData.find((sect) => sect.title === this.view).segments || []
function enterLegend(enter) {
let g = enter.append('g').attr('class', 'legend')
let shapes = g.append('g').attr('class', 'shape').attr('transform', 'translate(-32, -4)').attr('opacity', 1)
// Arc
shapes
.append('path')
.attr(
'd',
'M65,18.3536168 C56.2010616,10.6610132 44.6840625,6 32.0784255,6C19.8715569,6 8.68553955,10.3707832 0,17.6327229'
)
// Tick
shapes.append('path').attr('d', 'M32.5,5.5 L32.5,2.5')
g.append('text').attr('class', 'label').attr('opacity', 0).attr('font-size', 12).attr('fill', 'white')
g.append('text')
.attr('class', 'number')
.attr('fill', '#292858')
.attr('font-size', 16)
.style('font-weight', 'bold')
.style('pointer-events', 'none')
.attr('opacity', 0)
.attr('text-anchor', 'middle')
.attr('y', labelOffset + 5)
.attr('transform', 'translate(0,10)')
return g
}
let leg = this.legends
.selectAll('g.legend')
.data(viewData)
.join(enterLegend, (update) => update)
.attr('transform', `translate(${width / 2}, ${height / 2})`)
// Style the strokes
leg.selectAll('path').transition().attr('stroke', 'white').attr('stroke-width', 1).attr('fill', 'none')
leg
.select('text.label')
.text((d) => d.label)
.attr('transform', 'translate(0,6)')
.attr('opacity', 0)
.transition()
.duration(500)
.attr('opacity', 1)
.attr('text-anchor', 'middle')
.attr('transform', 'translate(0,-6)')
leg.select('g.shape').attr('opacity', 0.1).transition().duration(500).attr('opacity', 0.3)
leg
.attr('opacity', 0)
.transition()
.duration(1200)
.delay(400)
.attr('opacity', 1)
.attr('transform', (d, i) => {
const layout = config.layouts[viewData.length - 1][i]
return `translate(
${layout.x * width},
${layout.y * height - labelOffset}
)`
})
}
findNodePositions() {
const layoutNumber = config.sectionData.find((section) => section.title === this.view).segments.length
// Update the position for each node
config.data.forEach((d) => {
// When no view is specified, cluster all nodes in center
if (!this.view) {
d.target = {
x: width / 2,
y: height / 2,
}
} else if (this.view === 'default') {
// TODO: This should probably be fixed to find the center point dynamically,
// rather than hard coded like this.
d.target = {
x: d.startPos[0] * 2.5 * radius + 105,
y: d.startPos[1] * 2.5 * radius + 140,
}
} else {
d.target = {
x: config.layouts[layoutNumber - 1][d[this.view]].x * width,
y: config.layouts[layoutNumber - 1][d[this.view]].y * height,
}
}
})
}
startSimulation() {
this.simulation
.force('x', d3.forceX((d) => d.target.x).strength(0.15))
.force('y', d3.forceY((d) => d.target.y).strength(0.15))
.alpha(0.15)
.restart()
}
}
function initialCircleStyles(el) {
el.attr('r', 0).attr('opacity', 0).attr('fill', '#6EE9FF')
}
config.js
import * as d3 from 'd3'
// Mock data
export const sectionData = [
{
title: 'default',
text: {
header: 'Hvem er osloborgeren?',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [],
},
{
title: 'gender',
text: {
header: 'Kjønnsforskjeller',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [
{ value: 50, label: 'Menn' },
{ value: 50, label: 'Kvinner' },
],
},
{
title: 'familyType',
text: {
header: 'Familietype',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [
{ value: 41, label: 'Enslig Mann' },
{ value: 23, label: 'Enslig kvinne' },
{ value: 1, label: 'Enslig mann med barn' },
{ value: 14, label: 'Enslig kvinne med barn' },
{ value: 11, label: 'Par med barn' },
{ value: 10, label: 'Par uten barn' },
],
},
{
title: 'age',
text: {
header: 'Alder',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [
{ value: 15, label: '0–17' },
{ value: 25, label: '18–29' },
{ value: 24, label: '30–49' },
{ value: 20, label: '50–69' },
{ value: 12, label: '70–89' },
{ value: 4, label: '90+' },
],
},
{
title: 'married',
text: {
header: 'Sivilstatus',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [
{ value: 23, label: 'Gift' },
{ value: 77, label: 'Ugift' },
],
},
{
title: 'income',
text: {
header: 'Inntekt',
body:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum id tortor vehicula, faucibus neque vel, feugiat lacus. Phasellus eget tincidunt erat.',
},
segments: [
{ value: 14, label: '0–49k' },
{ value: 16, label: '50–149k' },
{ value: 19, label: '150–399k' },
{ value: 27, label: '400–599k' },
{ value: 14, label: '600–799k' },
{ value: 7, label: '800–999k' },
{ value: 3, label: '1M+' },
],
},
]
// Generates data for each node
export const data = (() => {
let data = []
d3.range(0, 100).forEach(() => data.push({}))
sectionData.forEach((section) => {
let counter = 0
section.segments.forEach((segment, index) => {
for (let i = 0; i < segment.value; i++) {
data[counter++][section.title] = index
}
})
})
return createStartGrid(data)
})()
function createStartGrid(data) {
const grid = [
[0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0],
]
// Then we loop the grid to grab the coordinates ...
const positions = []
grid.forEach((row, y) => {
row.forEach((cell, x) => {
if (cell) positions.push([x, y])
})
})
// ... and save them to the dataset.
// The startPos holds the coordinates for the '100' number
positions.forEach((pos, i) => {
data[i].startPos = pos
})
return data
}
export const layouts = [
[{ x: 0.5, y: 0.5 }],
[
{ x: 0.25, y: 0.5 },
{ x: 0.75, y: 0.5 },
],
[
{ x: 0.25, y: 0.66 },
{ x: 0.5, y: 0.33 },
{ x: 0.75, y: 0.66 },
],
[
{ x: 0.5, y: 0.25 },
{ x: 0.25, y: 0.5 },
{ x: 0.75, y: 0.5 },
{ x: 0.5, y: 0.75 },
],
[
{ x: 0.25, y: 0.25 },
{ x: 0.75, y: 0.25 },
{ x: 0.5, y: 0.5 },
{ x: 0.25, y: 0.75 },
{ x: 0.75, y: 0.75 },
],
[
{ x: 0.25, y: 0.33 },
{ x: 0.5, y: 0.25 },
{ x: 0.75, y: 0.33 },
{ x: 0.25, y: 0.66 },
{ x: 0.5, y: 0.75 },
{ x: 0.75, y: 0.66 },
],
[
{ x: 0.33, y: 0.25 },
{ x: 0.66, y: 0.25 },
{ x: 0.25, y: 0.5 },
{ x: 0.5, y: 0.5 },
{ x: 0.75, y: 0.5 },
{ x: 0.33, y: 0.75 },
{ x: 0.66, y: 0.75 },
],
]