import {rollup} from "../../../utils/functionUtils.js";
import {stack} from "https://cdn.skypack.dev/d3-shape@3";
const entitiesMap = {
states: {
label: "State",
cellsData: pack.cells.state,
getName: nameGetter("states"),
getColors: colorsGetter("states"),
landOnly: true
},
cultures: {
label: "Culture",
cellsData: pack.cells.culture,
getName: nameGetter("cultures"),
getColors: colorsGetter("cultures"),
landOnly: true
},
religions: {
label: "Religion",
cellsData: pack.cells.religion,
getName: nameGetter("religions"),
getColors: colorsGetter("religions"),
landOnly: true
},
provinces: {
label: "Province",
cellsData: pack.cells.province,
getName: nameGetter("provinces"),
getColors: colorsGetter("provinces"),
landOnly: true
},
biomes: {
label: "Biome",
cellsData: pack.cells.biome,
getName: biomeNameGetter,
getColors: biomeColorsGetter,
landOnly: false
}
};
const quantizationMap = {
total_population: {
label: "Total population",
quantize: cellId => getUrbanPopulation(cellId) + getRuralPopulation(cellId),
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
urban_population: {
label: "Urban population",
quantize: getUrbanPopulation,
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
rural_population: {
label: "Rural population",
quantize: getRuralPopulation,
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
area: {
label: "Land area",
quantize: cellId => getArea(pack.cells.area[cellId]),
aggregate: values => rn(d3.sum(values)),
formatTicks: value => `${si(value)} ${getAreaUnit()}`,
stringify: value => `${value.toLocaleString()} ${getAreaUnit()}`,
stackable: true,
landOnly: true
},
cells: {
label: "Number of cells",
quantize: () => 1,
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
burgs_number: {
label: "Number of burgs",
quantize: cellId => (pack.cells.burg[cellId] ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
average_elevation: {
label: "Average elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.mean(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
max_elevation: {
label: "Maximum mean elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.max(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
min_elevation: {
label: "Minimum mean elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.min(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
average_temperature: {
label: "Annual mean temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.mean(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
max_temperature: {
label: "Mean annual maximum temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.max(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
min_temperature: {
label: "Mean annual minimum temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.min(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
average_precipitation: {
label: "Annual mean precipitation",
quantize: cellId => grid.cells.prec[pack.cells.g[cellId]],
aggregate: values => rn(d3.mean(values)),
formatTicks: value => getPrecipitation(rn(value)),
stringify: value => getPrecipitation(rn(value)),
stackable: false,
landOnly: true
},
max_precipitation: {
label: "Mean annual maximum precipitation",
quantize: cellId => grid.cells.prec[pack.cells.g[cellId]],
aggregate: values => rn(d3.max(values)),
formatTicks: value => getPrecipitation(rn(value)),
stringify: value => getPrecipitation(rn(value)),
stackable: false,
landOnly: true
},
min_precipitation: {
label: "Mean annual minimum precipitation",
quantize: cellId => grid.cells.prec[pack.cells.g[cellId]],
aggregate: values => rn(d3.min(values)),
formatTicks: value => getPrecipitation(rn(value)),
stringify: value => getPrecipitation(rn(value)),
stackable: false,
landOnly: true
},
coastal_cells: {
label: "Number of coastal cells",
quantize: cellId => (pack.cells.t[cellId] === 1 ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
river_cells: {
label: "Number of river cells",
quantize: cellId => (pack.cells.r[cellId] ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
}
};
appendStyleSheet();
insertHtml();
addListeners();
changeViewColumns();
export function open() {
const charts = byId("chartsOverview__charts").childElementCount;
if (!charts) renderChart();
else $("#chartsOverview").dialog({title: "Data Charts"});
}
function appendStyleSheet() {
const style = document.createElement("style");
style.textContent = /* 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: 0 1% 0 4%;
display: grid;
grid-template-columns: 1fr auto;
}
`;
document.head.appendChild(style);
}
function insertHtml() {
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 */ `
`;
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() {
byId("chartsOverview__form").on("submit", renderChart);
byId("chartsOverview__viewColumns").on("change", changeViewColumns);
}
function renderChart(event) {
if (event) event.preventDefault();
const entity = byId("chartsOverview__entitiesSelect").value;
const plotBy = byId("chartsOverview__plotBySelect").value;
let groupBy = byId("chartsOverview__groupBySelect").value;
const sorting = byId("chartsOverview__sortingSelect").value;
const {
label: plotByLabel,
stringify,
quantize,
aggregate,
formatTicks,
stackable,
landOnly: plotByLandOnly
} = quantizationMap[plotBy];
if (!stackable && groupBy !== entity) {
tip("Grouping is not supported for this chart type", false, "warn", 4000);
groupBy = entity;
}
const noGrouping = groupBy === entity;
const {
label: entityLabel,
getName: getEntityName,
cellsData: entityCells,
landOnly: entityLandOnly
} = 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 = {};
const groups = new Set();
for (const cellId of pack.cells.i) {
if ((entityLandOnly || plotByLandOnly) && isWater(cellId)) continue;
const entityId = entityCells[cellId];
const groupId = groupCells[cellId];
const value = quantize(cellId);
if (!dataCollection[entityId]) dataCollection[entityId] = {[groupId]: [value]};
else if (!dataCollection[entityId][groupId]) dataCollection[entityId][groupId] = [value];
else dataCollection[entityId][groupId].push(value);
groups.add(groupId);
}
const chartData = Object.entries(dataCollection)
.map(([entityId, groupData]) => {
const name = getEntityName(entityId);
return Object.entries(groupData).map(([groupId, values]) => {
const group = getGroupName(groupId);
const value = aggregate(values);
return {name, group, value};
});
})
.flat();
const colors = getColors();
const chart = plot(chartData, {sorting, colors, formatTicks, tooltip});
insertChart(chart, title);
byId("chartsOverview__charts").lastChild.scrollIntoView();
updateDialog();
}
// based on observablehq.com/@d3/stacked-horizontal-bar-chart
function plot(data, {sorting, colors, formatTicks, tooltip}) {
const sortedData = sortData(data, sorting);
const X = sortedData.map(d => d.value);
const Y = sortedData.map(d => d.name);
const Z = sortedData.map(d => d.group);
const yDomain = new Set(Y);
const zDomain = new Set(Z);
const I = d3.range(X.length).filter(i => yDomain.has(Y[i]) && zDomain.has(Z[i]));
const height = yDomain.size * 25 + MARGIN.top + MARGIN.bottom;
const yRange = [height - MARGIN.bottom, MARGIN.top];
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(d3.stackOrderNone)
.offset(d3.stackOffsetDiverging)(rolled)
.map(s => {
const defined = s.filter(d => !isNaN(d[1]));
const data = defined.map(d => Object.assign(d, {i: d.data[1].get(s.key)}));
return {key: s.key, data};
});
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(Y_PADDING);
const xAxis = d3.axisTop(xScale).ticks(WIDTH / 80, null);
const yAxis = d3.axisLeft(yScale).tickSizeOuter(0);
const svg = d3
.create("svg")
.attr("version", "1.1")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("viewBox", [0, 0, WIDTH, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg
.append("g")
.attr("transform", `translate(0,${MARGIN.top})`)
.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 - MARGIN.top - MARGIN.bottom)
.attr("stroke-opacity", 0.1)
);
const bar = svg
.append("g")
.attr("stroke", "#666")
.attr("stroke-width", 0.5)
.selectAll("g")
.data(series)
.join("g")
.attr("fill", d => colors[d.key])
.selectAll("rect")
.data(d => d.data.filter(([x1, x2]) => x1 !== x2))
.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.on("mouseover", ({i}) => tooltip(Y[i], Z[i], X[i]));
svg
.append("g")
.attr("transform", `translate(${xScale(0)},0)`)
.call(yAxis);
const groups = Array.from(zDomain);
const minWidth = d3.max(groups.map(name => name.length)) * 8;
const maxInRow = Math.floor(WIDTH / minWidth);
const rows = Math.ceil(groups.length / maxInRow);
const rowElements = Math.floor(groups.length / rows);
const columnWidth = WIDTH / (rowElements + 0.5);
const rowHeight = 20;
const legend = svg
.append("g")
.attr("dominant-baseline", "central")
.attr("transform", `translate(${MARGIN.left},${height - MARGIN.bottom + 15})`);
legend
.selectAll("circle")
.data(groups)
.join("circle")
.attr("cx", (d, i) => (i % rowElements) * columnWidth)
.attr("cy", (d, i) => Math.floor(i / rowElements) * rowHeight)
.attr("r", 6)
.attr("fill", d => colors[d]);
legend
.selectAll("text")
.data(groups)
.join("text")
.attr("x", (d, i) => (i % rowElements) * columnWidth + 8)
.attr("y", (d, i) => Math.floor(i / rowElements) * rowHeight)
.text(d => d);
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 = /* html */ `
Figure ${figureNo}. ${title}
`;
$figure.appendChild(chart);
$figure.appendChild($caption);
$chartContainer.appendChild($figure);
const downloadChart = () => {
const name = `${getFileName(title)}.svg`;
downloadFile(chart.outerHTML, name);
};
const removeChart = () => {
$figure.remove();
updateDialog();
};
$figure.querySelector("button.icon-download").on("click", downloadChart);
$figure.querySelector("button.icon-trash").on("click", removeChart);
}
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: "svg"}});
}
// config
const NEUTRAL_COLOR = "#ccc";
const EMPTY_NAME = "no";
const MARGIN = {
top: 30,
right: 10,
bottom: 50,
left: 80
};
const WIDTH = 800;
const xRange = [MARGIN.left, WIDTH - MARGIN.right];
const Y_PADDING = 0.2;
function nameGetter(entity) {
return i => pack[entity][i].name || EMPTY_NAME;
}
function colorsGetter(entity) {
return () => Object.fromEntries(pack[entity].map(({name, color}) => [name || EMPTY_NAME, color || NEUTRAL_COLOR]));
}
function biomeNameGetter(i) {
return biomesData.name[i] || EMPTY_NAME;
}
function biomeColorsGetter() {
return Object.fromEntries(biomesData.i.map(i => [biomesData.name[i], biomesData.color[i]]));
}
function getUrbanPopulation(cellId) {
const burgId = pack.cells.burg[cellId];
if (!burgId) return 0;
const populationPoints = pack.burgs[burgId].population;
return populationPoints * populationRate * urbanization;
}
function getRuralPopulation(cellId) {
return pack.cells.pop[cellId] * populationRate;
}
function sortData(data, sorting) {
if (sorting === "natural") return data;
if (sorting === "name") {
return data.sort((a, b) => {
if (a.name !== b.name) return b.name.localeCompare(a.name); // reversed as 1st element is the bottom
return a.group.localeCompare(b.group);
});
}
if (sorting === "value") {
const entitySum = {};
const groupSum = {};
for (const {name, group, value} of data) {
entitySum[name] = (entitySum[name] || 0) + value;
groupSum[group] = (groupSum[group] || 0) + value;
}
return data.sort((a, b) => {
if (a.name !== b.name) return entitySum[a.name] - entitySum[b.name]; // reversed as 1st element is the bottom
return groupSum[b.group] - groupSum[a.group];
});
}
return data;
}