People as particles

Population density in cities, portrayed more vividly

D3/OJS
Published

March 29, 2023

Manila is the densest city on Earth,1 fitting 1.8 million people into a 43 square-kilometer crucible. The 16 other cities that constitute Metro Manila, the Philippine capital, are extraordinarily crowded as well, resulting in an overall density of 22,000/km². This is twice that of New York and almost three times that of Singapore. Dysfunctional urban planning has failed to cope. Suburbs-style neighborhoods and private golf courses sprawl incongruously in city centers while open green spaces remain limited. The state of transportation is dystopian. A much needed subway intended for partial operability in 2022 only started construction last January.

Living in such a crowded place means shoving, squeezing, and cartwheeling your way through the chaos of gridlocked streets and packed trains, a daily collision of body against body that brings to mind Radiohead at their most anxious: “everyone is so near”. It’s an experience that isn’t quite captured by the metric “22,000/km²”. I wanted to make this number more evocative through a visualization, but how? I’ve seen population density plotted on bar charts, on choropleth maps, even on a 3D histogram, but none of these, I feel, do justice to the absolute pressure cooker that is Manila.

I decided to try something novel. I call it a particles chart, and it consists of a D3 force simulation of balls bouncing off each other in a box. It’s relatively simple to construct — I am indebted to Vasco Asturiano’s modules in particular — yet it captures so effectively what “densest city on the planet” means. It means countless agitated bodies jockeying and jittering in a tight space, searching endlessly for room to breathe.2

Code
html`
  <div class="ojs-title">Fancy bumping into you</div>
  <div class="ojs-subtitle">Population density in Metro Manila and major global cities</div>
`
viewof citySelectPH = Inputs.select(
    d3.group(densityManila, d => d.city), {
        label: "Pick a Metro Manila city...",
        key: "Manila",
        description: "Pick a Metro Manila city..."
    })

viewof citySelect = Inputs.select(
    d3.group(densityOthers, d => d.city), {
        label: "...and compare with a global city",
        key: "New York"
    })

{
    const container = d3.create("div");

    const containerLegend = container
        .append("div")
        .attr("style", "display: flex; justify-content: center");
    containerLegend.call(legend);

    const containerChart = container
        .append("div")
        .attr("style", "display: flex; gap: 1em; justify-content: space-between");
    containerChart.call(particles, citySelectPH);
    containerChart.call(particles, citySelect);

    return container.node();
}
html`<div class="ojs-caption">Each box is a square kilometer. Each dot is 100 people. "Ancestors" date from 1500 CE, as derived from Putterman and Weil's <a href="https://sites.google.com/brown.edu/louis-putterman/world-migration-matrix-1500-2000" target="_blank">World Migration Matrix</a>.</div>`

I’ve set the balls to generate at various sizes, reflecting the fact that some people take up more space than others. Think private vehicles versus public transport, detached homes versus high-rise condominiums. The balls are also colored in proportion to ethnic makeup, as recorded in Putterman and Weil’s World Migration Matrix. I’ve written about this dataset here and here, but to recap, the matrix breaks down a country’s population according to where their ancestors were in the year 1500. Migrant ancestor balls are colored by originating continent, ordered from largest to smallest shares.

To be clear, density is not inherently bad. In fact, density is indicative of a successful city since it means people want to move there. Density itself also confers benefits through agglomeration effects: proximity among firms and laborers brings down production costs and increases knowledge spillovers. But these benefits surely fall short of potential in Metro Manila. How great can proximity advantages be if it takes two hours to get anywhere? If there were an optimal level of density for a given level of infrastructure quality, Manila has blown way past it.

The particles chart can be used in any number of applications, with three levers (the number, colors, and sizes of the particles) available for mapping to variables. I’ll end this post with an additional example, this time tackling a different aspect of modern city living. It’s not a statistic, but a feeling. The kind that creeps up on you late at night under certain moods, when the veil lifts and you momentarily perceive the Camusian absurdity of all your life’s endeavors. A feeling best encapsulated, I think, by another song lyric:

Code
{
    // Generate points
    const widthFishbowl = width
    const rFishbowl = widthFishbowl * .01
    const vFishbowl = rFishbowl * .1
    
    const dummynodes = Array.from(
        { length: 300 }, (_, i) => ({
            x: d3.randomUniform((widthFishbowl * .05), (widthFishbowl * .95))(),
            y: d3.randomUniform(height * .05, height * .95)(),
            r: d3.randomNormal(rMean, rSD)(),
            vx: d3.randomUniform(-1, 1)() * velocity,
            vy: d3.randomUniform(-1, 1)() * velocity,
            color: "#eeeeee"
        })
    );
    
    dummynodes.push(
        { x: widthFishbowl * .05, y: height * .95, r: rFishbowl, vx: vFishbowl, vy: -vFishbowl, color: "#4889ab" },
        { x: widthFishbowl * .95, y: height * .05, r: rFishbowl, vx: -vFishbowl, vy: vFishbowl, color: "#C85B89" }
    );
    
    const nodes = dummynodes.map(Object.create);

    // Panel
    const container = d3.create("div")
        .attr("style", "display: flex; justify-content: center");

    const svg = container.append("svg")
        .attr("width", widthFishbowl)
        .attr("height", height)
        .attr("viewBox", [0, 0, widthFishbowl, height])
        .attr("style", "max-width: 100%; height: auto; height: intrinsic");

    // Chart box
    svg.append("rect")
        .attr("width", widthFishbowl)
        .attr("height", height)
        .attr("fill", "#f7f7f7");

    const node = svg.append("g")
        .selectAll("circle")
        .data(nodes)
        .join("circle")
        .attr("cx", d => d.x)
        .attr("cy", d => d.y)
        .attr("r", d => d.r)
        .attr("fill", (d) => d.color);
    
    // Define forces
    const simulation = d3.forceSimulation(nodes)
        .force("bounce", d3.forceBounce()
            .radius(d => d.r + .5))
        .force("surface", d3.forceSurface()
            .surfaces(bbox([[0, 0], [widthFishbowl, height]]))
            .oneWay(true)
            .radius(d => d.r + 1))
        .force('limit', d3.forceLimit()
            .x0(0).x1(widthFishbowl).y0(0).y1(height))
        .alphaDecay(0)
        .velocityDecay(0)
        .on("tick", () => { node.attr("cx", d => d.x).attr("cy", d => d.y) });
    
    return container.node()
}

We’re just two lost souls swimming in a fish bowl
year after year

Data and cleaning script

D3 / Observable code

Code
d3 = require('d3@7', 'd3-force-bounce', 'd3-force-surface', 'd3-force-limit');

// Import data
density = FileAttachment('../../datasets/particles/density.csv').csv({ typed: true });
densityManila = density.filter(d => d.country === "Philippines");
densityOthers = density.filter(d => d.country !== "Philippines");
groupAssign = FileAttachment('../../datasets/particles/group_assign.csv').csv({ typed: true });

// Parameters
height = width * .49;                // Height of particle chart
rMean = (width * .49 / 40) / 2;      // Mean radius
rSD = rMean * .75;                   // SD radius
velocity = rMean * .8;               // Particle velocity

colors = ["#4889ab", "#7fc6a4", "#FCB13B", "#B13D70", "#f697bb"];

color = d3.scaleOrdinal()
    .domain([1, 2, 3, 4, 5])
    .range(colors);

bbox = ([[x1, y1], [x2, y2]]) => [
    { from: { x: x1, y: y1 }, to: { x: x1, y: y2 } },
    { from: { x: x1, y: y2 }, to: { x: x2, y: y2 } },
    { from: { x: x2, y: y2 }, to: { x: x2, y: y1 } },
    { from: { x: x2, y: y1 }, to: { x: x1, y: y1 } }
];

// Function to generate particles for a given city

function genpoints(city) {

    const density = Math.round(city[0].density / 100);
    
    const groupAssignCity = groupAssign.filter(d => d.city === city[0].city);
    const counts = groupAssignCity.map(row => row.points_ingroup);
    const groups = [1, 2, 3, 4, 5];
    const groupArray = counts.flatMap((count, i) =>
        Array.from({ length: count }, () => groups[i])
    );
    
    const points = Array.from(
        { length: density },
        (_, i) => ({
            x: d3.randomUniform((width * .49 * .15), (width * .49 * .85))(),
            y: d3.randomUniform(height * .15, height * .85)(),
            r: d3.randomNormal(rMean, rSD)(),
            vx: d3.randomUniform(-1, 1)() * velocity,
            vy: d3.randomUniform(-1, 1)() * velocity,
            group: groupArray[i]
        })
    );

    return points;
}

// Function to generate particles chart

function particles(selection, city) {

    // Read data
    const nodes = genpoints(city).map(Object.create);

    // Panel
    const svg = selection.append("svg")
        .attr("width", width * .49)
        .attr("height", height * 1.1)
        .attr("viewBox", [0, 0, width * .49, height * 1.1])
        .attr("style", "max-width: 100%; height: auto; height: intrinsic");

    // Chart box
    svg.append("rect")
        .attr("width", width * .49)
        .attr("height", height)
        .attr("fill", "#f7f7f7")
        .attr("stroke", "#bcbcbc")
        .attr("stroke-width", 1);

    // Bottom label
    const densityStat = d3.format(",.2r")(city[0].density);
    svg.append("text")
        .attr("id", "chart-text")
        .attr("x", width * .49 / 2)
        .attr("y", height * 1.075)
        .attr("text-anchor", "middle")
        .text(`${densityStat} people/km²`);

    // Draw particles
    const node = svg.append("g")
        .selectAll("circle")
        .data(nodes)
        .join("circle")
        .attr("cx", d => d.x)
        .attr("cy", d => d.y)
        .attr("r", d => d.r)
        .attr("fill", (d) => color(d.group));
    
    // Define forces
    const simulation = d3.forceSimulation(nodes)
        .force("bounce", d3.forceBounce()
            .radius(d => d.r + .5))
        .force("surface", d3.forceSurface()
            .surfaces(bbox([[0, 0], [width * .49, height]]))
            .oneWay(true)
            .radius(d => d.r + 1))
        .force('limit', d3.forceLimit()
            .x0(0).x1(width * .49).y0(0).y1(height))
        .alphaDecay(0)
        .velocityDecay(0)
        .on("tick", () => { node.attr("cx", d => d.x).attr("cy", d => d.y) });
}

// Function to generate legend

function legend(selection) {
    
    const legendWidth = 390;
    const legendHeight = 34;
    const rLegend = .5;
    
    const legendBox = selection.append("svg")
        .attr("width", legendWidth)
        .attr("height", legendHeight)
        .attr("viewBox", [0, 0, legendWidth, legendHeight])
        .attr("style", "max-width: 90%; height: auto; height: intrinsic;");
    
    legendBox.append("circle")
        .attr("cx", rLegend + "rem")
        .attr("cy", ".5rem")
        .attr("fill", "#4889ab")
        .attr("r", rLegend + "rem");

    legendBox.append("text")
        .attr("x", (rLegend * 3) + "rem")
        .attr("y", ".85rem")
        .attr("text-anchor", "left")
        .text("Native ancestors");

    legendBox.append("g")
        .selectAll("circle")
        .data([1, 2, 3, 4])
        .join("circle")
        .attr("cx", d => (9 + rLegend + (d - 1) * rLegend * 3) + "rem")
        .attr("cy", ".5rem")
        .attr("fill", d => color(d + 1))
        .attr("r", rLegend + "rem");

    legendBox.append("text")
        .attr("x", (9 + rLegend * 3 * 4) + "rem")
        .attr("y", ".85rem")
        .attr("text-anchor", "left")
        .text("Migrant ancestors");
}

Footnotes

  1. Other sources may differ depending on how city boundaries are defined.↩︎

  2. City density data were taken from Wikipedia. Comparisons are only suggestive given idiosyncrasies in how administrative boundaries are defined. For example, Metro Manila is 624 km², comparable to Jakarta (664 km²) and New York (778 km²) but not so much to Paris (105 km²) and Beijing (16,411 km²).↩︎

Reuse