Wealth and Health of Nations

_images/wealth-health.png
  1. Load and prepare data

import json
from bisect import bisect_left
from collections import namedtuple
from dataclasses import dataclass
from datetime import datetime
from operator import itemgetter

import detroit_live as d3
import requests

Margin = namedtuple("Margin", ("top", "right", "bottom", "left"))

URL = "https://static.observableusercontent.com/files/2953b6cf84ed92fb8fa449c1d2e2491075f6ede3d87822224a3108f5e40cb2cd2bee040c4e078863efbe06a2c125c846bbd596604b0c75ac11138a3093ad1126?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27nations.json"
nations = json.loads(requests.get(URL).content)


def parse_series(
    series: list[tuple[int, float]] | None,
) -> list[tuple[datetime, float]] | None:
    if series is None:
        return
    return [[datetime(year, 1, 1), value] for year, value in series]


@dataclass
class Data:
    name: str
    region: str
    income: list[tuple[datetime, float]] | float | None
    population: list[tuple[datetime, float]] | float | None
    life_expectancy: list[tuple[datetime, float]] | float | None


data = [
    Data(
        d.get("name"),
        d.get("region"),
        parse_series(d.get("income")),
        parse_series(d.get("population")),
        parse_series(d.get("lifeExpectancy")),
    )
    for d in nations
]
  1. Prepare the scatter chart

# Declare the chart dimensions and margins
width = 928
height = 560
margin = Margin(20, 20, 35, 40)


def data_index(d, index):
    return [d.income[index], d.population[index], d.life_expectancy[index]]


interval = d3.time_month
dates = interval.range(
    min(map(lambda d: min(map(itemgetter(0), data_index(d, -1))), data)),
    min(map(lambda d: max(map(itemgetter(0), data_index(d, -1))), data)),
)

# Declare the x (horizontal position) scale.
x = d3.scale_log([200, 1e5], [margin.left, width - margin.right])
# Declare the y (vertical position) scale.
y = d3.scale_linear([14, 86], [height - margin.bottom, margin.top])
# Declare the radius scale.
radius = d3.scale_sqrt([0, 5e8], [0, width / 24])
# Declare the color scale.
color = d3.scale_ordinal([d.region for d in data], d3.SCHEME_CATEGORY_10).set_unknown(
    "black"
)


# Returns value on a specified date
def value_at(values: list[tuple[datetime, float]], date: datetime) -> float:
    i = bisect_left(list(map(itemgetter(0), values)), date, 0, len(values) - 1)
    a = values[i]
    if i > 0:
        b = values[i - 1]
        t = (date - a[0]) / (b[0] - a[0])
        return a[1] * (1 - t) + b[1] * t
    return a[1]


# Returns data on a specified date
def data_at(date):
    return [
        Data(
            d.name,
            d.region,
            value_at(d.income, date),
            value_at(d.population, date),
            value_at(d.life_expectancy, date),
        )
        for d in data
    ]

# Add grid
def grid(g):
    def horizontal_lines(g):
        (
            g.append("g")
            .select_all("line")
            .data(x.ticks())
            .join("line")
            .attr("x1", lambda d: 0.5 + x(d))
            .attr("x2", lambda d: 0.5 + x(d))
            .attr("y1", margin.top)
            .attr("y2", height - margin.bottom)
        )

    def vertical_lines(g):
        (
            g.append("g")
            .select_all("line")
            .data(y.ticks())
            .join("line")
            .attr("y1", lambda d: 0.5 + y(d))
            .attr("y2", lambda d: 0.5 + y(d))
            .attr("x1", margin.left)
            .attr("x2", width - margin.right)
        )

    (
        g.attr("stroke", "currentColor")
        .attr("stroke-opacity", 0.1)
        .call(horizontal_lines)
        .call(vertical_lines)
    )


# Add the x-axis and x label
def x_axis(g):
    (
        g.attr("transform", f"translate(0, {height - margin.bottom})")
        .call(d3.axis_bottom(x).set_ticks(width / 80, ","))
        .call(lambda g: g.select(".domain").remove())
        .call(
            lambda g: (
                g.append("text")
                .attr("x", width)
                .attr("y", margin.bottom - 4)
                .attr("fill", "currentColor")
                .attr("text-anchor", "end")
                .text("Income per capita (dollars) →")
            )
        )
    )


# Add the y-axis and y label
def y_axis(g):
    (
        g.attr("transform", f"translate({margin.left}, 0)")
        .call(d3.axis_left(y))
        .call(lambda g: g.select(".domain").remove())
        .call(
            lambda g: (
                g.append("text")
                .attr("x", -margin.left)
                .attr("y", 10)
                .attr("fill", "currentColor")
                .attr("text-anchor", "start")
                .text("↑ Life expectancy (years)")
            )
        )
    )


# Create containers
html = d3.create("html")
body = html.append("body")
svg = (
    body.append("div")
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", [0, 0, width, height])
)

svg.append("g").call(x_axis)
svg.append("g").call(y_axis)
svg.append("g").call(grid)

circle = (
    svg.append("g")
    .attr("stroke", "black")
    .select_all("circle")
    .data(data_at(datetime(1800, 1, 1)), lambda d: d.name)
    .join("circle")
    .attr("cx", lambda d: x(d.income))
    .attr("cy", lambda d: y(d.life_expectancy))
    .attr("r", lambda d: radius(d.population))
    .attr("fill", lambda d: color(d.region))
    .call(
        lambda circle: circle.append("title").text(
            lambda d: "\n".join((d.name, d.region))
        )
    )
)
  1. Create event producers mixed with event listeners.

# Add play button and slider before SVG
buttons = (
    body.insert("div", "svg")
    .attr(
        "style",
        "font: 12px var(--sans-serif);"
        " font-variant-numeric: tabular-nums;"
        " display: flex; height: 33px; align-items: center;"
    )
)
play_button = (
    buttons.append("button")
    .attr("name", "play")
    .attr("style", "margin-right: 0.4em; width: 5em;")
    .text("Play")
)
slider = (
    buttons.append("input")
    .attr("name", "year")
    .attr("type", "range")
    .attr("min", "1800")
    .attr("max", "2006")
    .attr("value", "1800")
    .attr("step", "1")
    .attr("style", "width: 180px;")
)
span = body.insert("div", "svg").append("span").text("Year: 1800")

# Convenient class to keep play button and slider states
class ButtonState:
    def __init__(self):
        self.is_pause = False
        self.slider_value = 1800
        self.event_producers = d3.event_producers()
        self.timer_modifier = None

    # Play button: Listener callback
    def play_event(self, event, d, node):
        if self.is_pause:
            play_button.text("Play")
            if self.timer_modifier is not None:
                self.timer_modifier.stop()
            self.is_pause = False
        else:
            play_button.text("Pause")
            # Create a event producer (interval timer)
            self.timer_modifier = self.event_producers.add_interval(
                self.increase_slider,
                updated_nodes=circle.nodes() + span.nodes() + slider.nodes(),
                html_nodes=span.nodes(),
                delay=50,
            )
            self.is_pause = True

    def update(self):
        date = self.slider_value
        slider.attr("value", date)
        current_data = data_at(datetime(date, 1, 1))
        (
            circle.data(current_data, lambda d: d.name)
            .attr("cx", lambda d: x(d.income))
            .attr("cy", lambda d: y(d.life_expectancy))
            .attr("r", lambda d: radius(d.population))
        )
        span.text(f"Year: {date}")

    # Slider: Listener callback
    def slider_event(self, event, d, node):
        play_button.text("Play")
        if self.timer_modifier is not None:
            self.timer_modifier.stop()
        self.is_pause = False

        self.slider_value = int(event.value)
        self.update()

    # Producer callback
    def increase_slider(self, elapsed, timer_event):
        if self.slider_value > 2005:
            timer_event.set()
            return

        self.slider_value += 1
        self.update()

button_state = ButtonState()

# Add event listeners to play button and slider
play_button.on("click", button_state.play_event, html_nodes=play_button.nodes())
slider.on(
    "input",
    button_state.slider_event,
    extra_nodes=circle.nodes() + span.nodes() + slider.nodes(),
    html_nodes=span.nodes() + play_button.nodes(),
)
  1. Create an application and run it locally

html.create_app().run()

Note

Since the transition are not implemented in the current version, the animation is not as smooth as the d3js example.