diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js
index 5b37983a..9684c3ba 100644
--- a/modules/dynamic/overview/charts-overview.js
+++ b/modules/dynamic/overview/charts-overview.js
@@ -1,48 +1,112 @@
import {rollup} from "../../../utils/functionUtils.js";
import {stack} from "https://cdn.skypack.dev/d3-shape@3";
-const entities = ["states", "cultures", "religions"];
-const quantitatives = ["total_population", "urban_population", "rural_population", "area", "cells"];
-const groupings = ["cultures", "states", "religions"];
-
-const dataMap = {
- states: {array: pack.states, getName: i => pack.states[i].name, cellsData: pack.cells.state},
- cultures: {array: pack.cultures, getName: i => pack.cultures[i].name, cellsData: pack.cells.culture},
- religions: {array: pack.religions, getName: i => pack.religions[i].name, cellsData: pack.cells.religion}
+const entitiesMap = {
+ states: {
+ label: "State",
+ array: pack.states,
+ cellsData: pack.cells.state,
+ getName: nameGetter("states"),
+ getColors: colorsGetter("states")
+ },
+ cultures: {
+ label: "Culture",
+ array: pack.cultures,
+ cellsData: pack.cells.culture,
+ getName: nameGetter("cultures"),
+ getColors: colorsGetter("cultures")
+ },
+ religions: {
+ label: "Religion",
+ array: pack.religions,
+ cellsData: pack.cells.religion,
+ getName: nameGetter("religions"),
+ getColors: colorsGetter("religions")
+ }
};
const quantizationMap = {
- total_population: cellId => getUrbanPopulation(cellId) + getRuralPopulation(cellId),
- urban_population: getUrbanPopulation,
- rural_population: getRuralPopulation,
- area: cellId => getArea(pack.cells.area[cellId]),
- cells: () => 1
-};
-
-const sortingMap = {
- value: (a, b) => b.value - a.value,
- name: (a, b) => a.name.localeCompare(b.name)
+ total_population: {
+ label: "Total population",
+ quantize: cellId => getUrbanPopulation(cellId) + getRuralPopulation(cellId),
+ formatTicks: value => si(value),
+ stringify: value => `${value.toLocaleString()} people`
+ },
+ urban_population: {
+ label: "Urban population",
+ quantize: getUrbanPopulation,
+ formatTicks: value => si(value),
+ stringify: value => `${value.toLocaleString()} people`
+ },
+ rural_population: {
+ label: "Rural population",
+ quantize: getRuralPopulation,
+ formatTicks: value => si(value),
+ stringify: value => `${value.toLocaleString()} people`
+ },
+ area: {
+ label: "Land area",
+ quantize: cellId => getArea(pack.cells.area[cellId]),
+ formatTicks: value => `${si(value)} ${getAreaUnit()}`,
+ stringify: value => `${value.toLocaleString()} ${getAreaUnit()}`
+ },
+ cells: {
+ label: "Number of cells",
+ quantize: () => 1,
+ formatTicks: value => value,
+ stringify: value => `${value.toLocaleString()} cells`
+ }
};
appendStyleSheet();
insertHtml();
-const $entitiesSelect = byId("chartsOverview__entitiesSelect");
-const $plotBySelect = byId("chartsOverview__plotBySelect");
-const $groupBySelect = byId("chartsOverview__groupBySelect");
-updateSelectorOptions();
addListeners();
+changeViewColumns();
export function open() {
- renderChart();
+ const charts = byId("chartsOverview__charts").childElementCount;
+ if (!charts) renderChart();
- $("#chartsOverview").dialog({
- title: "Charts"
- });
+ $("#chartsOverview").dialog({title: "Data Charts"});
}
function appendStyleSheet() {
const styles = /* css */ `
+ #chartsOverview {
+ max-width: 90vw !important;
+ max-height: 90vh !important;
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ }
+
+ #chartsOverview__form {
+ font-size: 1.1em;
+ margin: 0.3em;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ #chartsOverview__charts {
+ overflow: auto;
+ scroll-behavior: smooth;
+ display: grid;
+ }
+
+ #chartsOverview__charts figure {
+ margin: 0;
+ }
+
+ #chartsOverview__charts figcaption {
+ font-size: 1.2em;
+ margin-left: 4%;
+ }
+
+ .chartsOverview__bars {
+ stroke: #666;
+ stroke-width: 0.5;
+ }
`;
const style = document.createElement("style");
@@ -51,48 +115,83 @@ function appendStyleSheet() {
}
function insertHtml() {
- const createOption = value => ``;
+ const entities = Object.entries(entitiesMap).map(([entity, {label}]) => [entity, label]);
+ const plotBy = Object.entries(quantizationMap).map(([plotBy, {label}]) => [plotBy, label]);
+
+ const createOption = ([value, label]) => ``;
const createOptions = values => values.map(createOption).join("");
- const html = /* html */ `
-
-
Plot
-
+ const html = /* html */ `
+
+ Columns
+
+
+
+
+
`;
byId("dialogs").insertAdjacentHTML("beforeend", html);
+
+ // set defaults
+ byId("chartsOverview__entitiesSelect").value = "states";
+ byId("chartsOverview__plotBySelect").value = "total_population";
+ byId("chartsOverview__groupBySelect").value = "cultures";
}
function addListeners() {
- $entitiesSelect.on("change", renderChart);
- $plotBySelect.on("change", renderChart);
- $groupBySelect.on("change", renderChart);
-
- $entitiesSelect.on("change", updateSelectorOptions);
- $groupBySelect.on("change", updateSelectorOptions);
+ byId("chartsOverview__form").on("submit", renderChart);
+ byId("chartsOverview__viewColumns").on("change", changeViewColumns);
}
-function renderChart() {
- const entity = $entitiesSelect.value;
- const plotBy = $plotBySelect.value;
- const groupBy = $groupBySelect.value;
+function renderChart(event) {
+ if (event) event.preventDefault();
+
+ const entity = byId("chartsOverview__entitiesSelect").value;
+ const plotBy = byId("chartsOverview__plotBySelect").value;
+ const groupBy = byId("chartsOverview__groupBySelect").value;
+ const sorting = byId("chartsOverview__sortingSelect").value;
+
+ const noGrouping = groupBy === entity;
const filterWater = true;
const filterZeroes = true;
- const sorting = sortingMap["value"];
- const {getName: getEntityName, cellsData: entityCells} = dataMap[entity];
- const {getName: getGroupName, cellsData: groupCells} = dataMap[groupBy];
- const quantize = quantizationMap[plotBy];
+ const {label: plotByLabel, stringify, quantize, formatTicks} = quantizationMap[plotBy];
+ const {label: entityLabel, getName: getEntityName, cellsData: entityCells} = entitiesMap[entity];
+ const {label: groupLabel, getName: getGroupName, cellsData: groupCells, getColors} = entitiesMap[groupBy];
+
+ const title = `${capitalize(entity)} by ${plotByLabel}${noGrouping ? "" : " grouped by " + groupLabel}`;
+
+ const tooltip = (entity, group, value) => {
+ const entityTip = `${entityLabel}: ${entity}`;
+ const groupTip = noGrouping ? "" : `${groupLabel}: ${group}`;
+ const valueTip = `${plotByLabel}: ${stringify(value)}`;
+ tip([entityTip, groupTip, valueTip].filter(Boolean).join(". "));
+ };
const dataCollection = {};
for (const cellId of pack.cells.i) {
@@ -118,77 +217,66 @@ function renderChart() {
.flat();
const chartDataFiltered = filterZeroes ? chartData.filter(({value}) => value > 0) : chartData;
+ const colors = getColors();
- const chart = plot(chartDataFiltered, {sorting});
- byId("chartsOverview__svgContainer").appendChild(chart);
+ const chart = plot(chartDataFiltered, {sorting, colors, formatTicks, tooltip});
+ insertChart(chart, title);
+
+ byId("chartsOverview__charts").lastChild.scrollIntoView();
+ updateDialog();
}
-function updateSelectorOptions() {
- const entity = $entitiesSelect.value;
- $groupBySelect.querySelector("option[disabled]")?.removeAttribute("disabled");
- $groupBySelect.querySelector(`option[value="${entity}"]`)?.setAttribute("disabled", "");
-
- const group = $groupBySelect.value;
- $entitiesSelect.querySelector("option[disabled]")?.removeAttribute("disabled");
- $entitiesSelect.querySelector(`option[value="${group}"]`)?.setAttribute("disabled", "");
-}
-
-// based on https://observablehq.com/@d3/grouped-bar-chart
+// based on observablehq.com/@d3/stacked-horizontal-bar-chart
function plot(
data,
{
marginTop = 30, // top margin, in pixels
- marginRight = 0, // right margin, in pixels
- marginBottom = 40, // bottom margin, in pixels
- marginLeft = 100, // left margin, in pixels
+ marginRight = 10, // right margin, in pixels
+ marginBottom = 10, // bottom margin, in pixels
+ marginLeft = 80, // left margin, in pixels
width = 800, // outer width, in pixels
xRange = [marginLeft, width - marginRight], // [xmin, xmax]
yPadding = 0.2,
- xFormat,
- xLabel = "Population (millions) →",
- sorting
+ sorting,
+ colors,
+ formatTicks,
+ tooltip
} = {}
) {
const X = data.map(d => d.value);
const Y = data.map(d => d.name);
const Z = data.map(d => d.group);
- const yDomain = new Set(Y); // get from parent, already sorted
+ const sortedY = sortData({array: Y, sorting, data, dataKey: "name", reverse: true});
+ const sortedZ = sortData({array: Z, sorting, data, dataKey: "group", reverse: false});
+ const yDomain = new Set(sortedY);
const zDomain = new Set(Z);
- // omit any data not present in both the y- and z-domain
- const I = d3.range(X.length).filter(i => yDomain.has(Y[i]) && zDomain.has(Z[i]));
+ const I = d3.range(X.length).filter(i => X[i] > 0 && yDomain.has(Y[i]) && zDomain.has(Z[i]));
const height = yDomain.size * 25 + marginTop + marginBottom;
const yRange = [height - marginBottom, marginTop];
- const offset = d3.stackOffsetDiverging;
- const order = d3.stackOrderNone;
+ const rolled = rollup(...[I, ([i]) => i, i => Y[i], i => Z[i]]);
const series = stack()
.keys(zDomain)
.value(([, I], z) => X[I.get(z)])
- .order(order)
- .offset(offset)(
- rollup(
- I,
- ([i]) => i,
- i => Y[i],
- i => Z[i]
- )
- )
- .map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
+ .order(d3.stackOrderNone)
+ .offset(d3.stackOffsetDiverging)(rolled)
+ .map(s => {
+ const nonNull = s.filter(d => Boolean(d[1]));
+ const data = nonNull.map(d => Object.assign(d, {i: d.data[1].get(s.key)}));
+ return {key: s.key, data};
+ });
- const xDomain = d3.extent(series.flat(2));
+ const xDomain = d3.extent(series.map(d => d.data).flat(2));
const xScale = d3.scaleLinear(xDomain, xRange);
const yScale = d3.scaleBand(Array.from(yDomain), yRange).paddingInner(yPadding);
- const color = d3.scaleOrdinal(Array.from(zDomain), d3.schemeCategory10);
- const xAxis = d3.axisTop(xScale).ticks(width / 80, xFormat);
- const yAxis = d3.axisLeft(yScale).tickSizeOuter(0);
- const formatValue = xScale.tickFormat(100, xFormat);
- const title = i => `${Y[i]}\n${Z[i]}\n${formatValue(X[i])}`;
+ const xAxis = d3.axisTop(xScale).ticks(width / 80, null);
+ const yAxis = d3.axisLeft(yScale).tickSizeOuter(0);
const svg = d3
.create("svg")
@@ -202,48 +290,77 @@ function plot(
.attr("transform", `translate(0,${marginTop})`)
.call(xAxis)
.call(g => g.select(".domain").remove())
+ .call(g => g.selectAll("text").text(d => formatTicks(d)))
.call(g =>
g
.selectAll(".tick line")
.clone()
.attr("y2", height - marginTop - marginBottom)
.attr("stroke-opacity", 0.1)
- )
- .call(g =>
- g
- .append("text")
- .attr("x", width - marginRight)
- .attr("y", -22)
- .attr("fill", "currentColor")
- .attr("text-anchor", "end")
- .text(xLabel)
);
const bar = svg
.append("g")
+ .attr("class", "chartsOverview__bars")
.selectAll("g")
.data(series)
.join("g")
- .attr("fill", ([{i}]) => color(Z[i]))
+ .attr("fill", d => colors[d.key])
.selectAll("rect")
- .data(d => d.filter(d => d.i !== undefined))
+ .data(d => d.data)
.join("rect")
.attr("x", ([x1, x2]) => Math.min(xScale(x1), xScale(x2)))
.attr("y", ({i}) => yScale(Y[i]))
.attr("width", ([x1, x2]) => Math.abs(xScale(x1) - xScale(x2)))
.attr("height", yScale.bandwidth());
- bar.append("title").text(({i}) => title(i));
+ bar.on("mouseover", ({i}) => tooltip(Y[i], Z[i], X[i]));
svg
.append("g")
.attr("transform", `translate(${xScale(0)},0)`)
.call(yAxis);
- return Object.assign(svg.node(), {scales: {color}});
+ return svg.node();
}
+function insertChart(chart, title) {
+ const $chartContainer = byId("chartsOverview__charts");
+
+ const $figure = document.createElement("figure");
+ const $caption = document.createElement("figcaption");
+
+ const figureNo = $chartContainer.childElementCount + 1;
+ $caption.innerHTML = `
Figure ${figureNo}. ${title}`;
+
+ $figure.appendChild(chart);
+ $figure.appendChild($caption);
+
+ $chartContainer.appendChild($figure);
+}
+
+function changeViewColumns() {
+ const columns = byId("chartsOverview__viewColumns").value;
+ const $charts = byId("chartsOverview__charts");
+ $charts.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
+ updateDialog();
+}
+
+function updateDialog() {
+ $("#chartsOverview").dialog({position: {my: "center", at: "center", of: window}});
+}
+
+// config
+const NEUTRAL_COLOR = "#ccc";
+
// helper functions
+function nameGetter(entity) {
+ return i => pack[entity][i].name;
+}
+function colorsGetter(entity) {
+ return () => Object.fromEntries(pack[entity].map(({name, color}) => [name, color || NEUTRAL_COLOR]));
+}
+
function getUrbanPopulation(cellId) {
const burgId = pack.cells.burg[cellId];
if (!burgId) return 0;
@@ -254,3 +371,18 @@ function getUrbanPopulation(cellId) {
function getRuralPopulation(cellId) {
return pack.cells.pop[cellId] * populationRate;
}
+
+function sortData({array, sorting, data, dataKey, reverse}) {
+ if (sorting === "natural") return array;
+ if (sorting === "name") return array.sort((a, b) => (reverse ? b.localeCompare(a) : a.localeCompare(b)));
+
+ if (sorting === "value") {
+ return [...new Set(array)].sort((a, b) => {
+ const valueA = d3.sum(data.filter(d => d[dataKey] === a).map(d => d.value));
+ const valueB = d3.sum(data.filter(d => d[dataKey] === b).map(d => d.value));
+ return reverse ? valueA - valueB : valueB - valueA;
+ });
+ }
+
+ return array;
+}