Oslo as 100 persons

  • Data visualisation
  • Scrollytelling
  • D3.js
screenshot of the visualisation
See full demo

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:

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 },
  ],
]