From b00b9db3b599eaa6dd44f2ad599f0e56b0fda57f Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 19 Jun 2022 13:30:25 +0300 Subject: [PATCH] feat(charts): stack bar chart UI --- modules/dynamic/overview/charts-overview.js | 340 ++++++++++++++------ 1 file changed, 236 insertions(+), 104 deletions(-) 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 */ `
+
+
+ + - by - + by + - grouped by - -
+ grouped by + -
+ sorted + +
+
+ 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; +}