From be5589daeb2ed3514468d53ad66ae38c211846a2 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Wed, 15 Jun 2022 02:47:32 +0300 Subject: [PATCH 01/12] feat(charts): add basic data collection and rendering --- index.html | 7 + modules/dynamic/hierarchy-tree.js | 3 - modules/dynamic/overview/charts-overview.js | 230 ++++++++++++++++++++ modules/ui/hotkeys.js | 32 ++- modules/ui/tools.js | 6 + versioning.js | 2 +- 6 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 modules/dynamic/overview/charts-overview.js diff --git a/index.html b/index.html index 3c9dcacf..82c0b8db 100644 --- a/index.html +++ b/index.html @@ -1967,6 +1967,13 @@

Click to overview:

+ diff --git a/modules/dynamic/hierarchy-tree.js b/modules/dynamic/hierarchy-tree.js index 73b9f173..e3f06dde 100644 --- a/modules/dynamic/hierarchy-tree.js +++ b/modules/dynamic/hierarchy-tree.js @@ -1,6 +1,5 @@ appendStyleSheet(); insertHtml(); -addListeners(); const MARGINS = {top: 10, right: 10, bottom: -5, left: 10}; @@ -159,8 +158,6 @@ function insertHtml() { byId("dialogs").insertAdjacentHTML("beforeend", html); } -function addListeners() {} - function getRoot() { const root = d3 .stratify() diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js new file mode 100644 index 00000000..3bee1ebf --- /dev/null +++ b/modules/dynamic/overview/charts-overview.js @@ -0,0 +1,230 @@ +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 quantizationMap = { + total_population: cellId => getUrbanPopulation(cellId) + getRuralPopulation(cellId), + urban_population: getUrbanPopulation, + rural_population: getRuralPopulation, + area: cellId => getArea(pack.cells.area[cellId]), + cells: () => 1 +}; + +appendStyleSheet(); + +insertHtml(); +const $entitiesSelect = byId("chartsOverview__entitiesSelect"); +const $plotBySelect = byId("chartsOverview__plotBySelect"); +const $groupBySelect = byId("chartsOverview__groupBySelect"); +updateSelectorOptions(); +addListeners(); + +export function open() { + renderChart(); + + $("#chartsOverview").dialog({ + title: "Charts" + }); +} + +function appendStyleSheet() { + const styles = /* css */ ` + `; + + const style = document.createElement("style"); + style.appendChild(document.createTextNode(styles)); + document.head.appendChild(style); +} + +function insertHtml() { + const createOption = value => ``; + const createOptions = values => values.map(createOption).join(""); + + const html = /* html */ `
+
+ Plot + + + by + + + grouped by + +
+ +
+
`; + + byId("dialogs").insertAdjacentHTML("beforeend", html); +} + +function addListeners() { + $entitiesSelect.on("change", renderChart); + $plotBySelect.on("change", renderChart); + $groupBySelect.on("change", renderChart); + + $entitiesSelect.on("change", updateSelectorOptions); + $groupBySelect.on("change", updateSelectorOptions); +} + +function renderChart() { + const entity = $entitiesSelect.value; + const plotBy = $plotBySelect.value; + const groupBy = $groupBySelect.value; + + const {array: entityArray, getName: getEntityName, cellsData: entityCells} = dataMap[entity]; + const {getName: getGroupName, cellsData: groupCells} = dataMap[groupBy]; + const quantize = quantizationMap[plotBy]; + + const chartData = entityArray + .filter(element => !element.removed) + .map(({i}) => { + const cells = pack.cells.i.filter(cellId => entityCells[cellId] === i); + const name = getEntityName(i); + + return Array.from(cells).map(cellId => { + const group = getGroupName(groupCells[cellId]); + const value = quantize(cellId); + return {name, group, value}; + }); + }) + .flat(); + + console.log(chartData); + const chart = plot(chartData, {}); + byId("chartsOverview__svgContainer").appendChild(chart); +} + +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 +function plot( + data, + { + title, // given d in data, returns the title text + marginTop = 30, // top margin, in pixels + marginRight = 0, // right margin, in pixels + marginBottom = 40, // bottom margin, in pixels + marginLeft = 100, // left margin, in pixels + width = 2400, // outer width, in pixels + height = 400, // outer height, in pixels + xRange = [marginLeft, width - marginRight], // [xmin, xmax] + xPadding = 0.1, // amount of x-range to reserve to separate groups + yType = d3.scaleLinear, // type of y-scale + yRange = [height - marginBottom, marginTop], // [ymin, ymax] + zPadding = 0.05, // amount of x-range to reserve to separate bars + yFormat, // a format specifier string for the y-axis + yLabel, // a label for the y-axis + colors = d3.schemeCategory10 // array of colors + } = {} +) { + const X = data.map(d => d.name); + const Y = data.map(d => d.value); + const Z = data.map(d => d.group); + + const xDomain = new Set(X); + const yDomain = [0, d3.max(Y)]; + const zDomain = new Set(Z); + + // omit any data not present in both the x- and z-domain + const I = d3.range(X.length).filter(i => xDomain.has(X[i]) && zDomain.has(Z[i])); + + const xDomainArray = Array.from(xDomain); + const zDomainArray = Array.from(zDomain); + + // Construct scales, axes, and formats + const xScale = d3.scaleBand(xDomainArray, xRange).paddingInner(xPadding); + const xzScale = d3.scaleBand(zDomainArray, [0, xScale.bandwidth()]).padding(zPadding); + const yScale = yType(yDomain, yRange); + const zScale = d3.scaleOrdinal(zDomainArray, colors); + const xAxis = d3.axisBottom(xScale).tickSizeOuter(0); + const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat); + + // Compute titles + if (title === undefined) { + const formatValue = yScale.tickFormat(100, yFormat); + title = i => `${X[i]}\n${Z[i]}\n${formatValue(Y[i])}`; + } else { + const O = d3.map(data, d => d); + const T = title; + title = i => T(O[i], i, data); + } + + const svg = d3 + .create("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); + + svg + .append("g") + .attr("transform", `translate(${marginLeft},0)`) + .call(yAxis) + .call(g => g.select(".domain").remove()) + .call(g => + g + .selectAll(".tick line") + .clone() + .attr("x2", width - marginLeft - marginRight) + .attr("stroke-opacity", 0.1) + ) + .call(g => + g + .append("text") + .attr("x", -marginLeft) + .attr("y", 10) + .attr("fill", "currentColor") + .attr("text-anchor", "start") + .text(yLabel) + ); + + const bar = svg + .append("g") + .selectAll("rect") + .data(I) + .join("rect") + .attr("x", i => xScale(X[i]) + xzScale(Z[i])) + .attr("y", i => yScale(Y[i])) + .attr("width", xzScale.bandwidth()) + .attr("height", i => yScale(0) - yScale(Y[i])) + .attr("fill", i => zScale(Z[i])); + + if (title) bar.append("title").text(title); + + svg + .append("g") + .attr("transform", `translate(0,${height - marginBottom})`) + .call(xAxis); + + const chart = Object.assign(svg.node(), {scales: {color: zScale}}); + console.log(chart); + return chart; +} + +// helper functions +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) { + const populationPoints = pack.cells.pop[cellId] * populationRate; + return populationPoints * populationRate; +} diff --git a/modules/ui/hotkeys.js b/modules/ui/hotkeys.js index 9c9a4a7f..2c5a9a3a 100644 --- a/modules/ui/hotkeys.js +++ b/modules/ui/hotkeys.js @@ -49,6 +49,7 @@ function handleKeyup(event) { else if (shift && code === "KeyY") openEmblemEditor(); else if (shift && code === "KeyQ") editUnits(); else if (shift && code === "KeyO") editNotes(); + else if (shift && code === "KeyA") overviewCharts(); else if (shift && code === "KeyT") overviewBurgs(); else if (shift && code === "KeyV") overviewRivers(); else if (shift && code === "KeyM") overviewMilitary(); @@ -114,12 +115,17 @@ function pressNumpadSign(key) { let brush = null; if (document.getElementById("brushRadius")?.offsetParent) brush = document.getElementById("brushRadius"); - else if (document.getElementById("biomesManuallyBrush")?.offsetParent) brush = document.getElementById("biomesManuallyBrush"); - else if (document.getElementById("statesManuallyBrush")?.offsetParent) brush = document.getElementById("statesManuallyBrush"); - else if (document.getElementById("provincesManuallyBrush")?.offsetParent) brush = document.getElementById("provincesManuallyBrush"); - else if (document.getElementById("culturesManuallyBrush")?.offsetParent) brush = document.getElementById("culturesManuallyBrush"); + else if (document.getElementById("biomesManuallyBrush")?.offsetParent) + brush = document.getElementById("biomesManuallyBrush"); + else if (document.getElementById("statesManuallyBrush")?.offsetParent) + brush = document.getElementById("statesManuallyBrush"); + else if (document.getElementById("provincesManuallyBrush")?.offsetParent) + brush = document.getElementById("provincesManuallyBrush"); + else if (document.getElementById("culturesManuallyBrush")?.offsetParent) + brush = document.getElementById("culturesManuallyBrush"); else if (document.getElementById("zonesBrush")?.offsetParent) brush = document.getElementById("zonesBrush"); - else if (document.getElementById("religionsManuallyBrush")?.offsetParent) brush = document.getElementById("religionsManuallyBrush"); + else if (document.getElementById("religionsManuallyBrush")?.offsetParent) + brush = document.getElementById("religionsManuallyBrush"); if (brush) { const value = minmax(+brush.value + change, +brush.min, +brush.max); @@ -133,18 +139,26 @@ function pressNumpadSign(key) { function toggleMode() { if (zonesRemove?.offsetParent) { - zonesRemove.classList.contains("pressed") ? zonesRemove.classList.remove("pressed") : zonesRemove.classList.add("pressed"); + zonesRemove.classList.contains("pressed") + ? zonesRemove.classList.remove("pressed") + : zonesRemove.classList.add("pressed"); } } function removeElementOnKey() { - const fastDelete = Array.from(document.querySelectorAll("[role='dialog'] .fastDelete")).find(dialog => dialog.style.display !== "none"); + const fastDelete = Array.from(document.querySelectorAll("[role='dialog'] .fastDelete")).find( + dialog => dialog.style.display !== "none" + ); if (fastDelete) fastDelete.click(); - const visibleDialogs = Array.from(document.querySelectorAll("[role='dialog']")).filter(dialog => dialog.style.display !== "none"); + const visibleDialogs = Array.from(document.querySelectorAll("[role='dialog']")).filter( + dialog => dialog.style.display !== "none" + ); if (!visibleDialogs.length) return; - visibleDialogs.forEach(dialog => dialog.querySelectorAll("button").forEach(button => button.textContent === "Remove" && button.click())); + visibleDialogs.forEach(dialog => + dialog.querySelectorAll("button").forEach(button => button.textContent === "Remove" && button.click()) + ); } function closeAllDialogs() { diff --git a/modules/ui/tools.js b/modules/ui/tools.js index aad90578..27678921 100644 --- a/modules/ui/tools.js +++ b/modules/ui/tools.js @@ -19,6 +19,7 @@ toolsContent.addEventListener("click", function (event) { else if (button === "editUnitsButton") editUnits(); else if (button === "editNotesButton") editNotes(); else if (button === "editZonesButton") editZones(); + else if (button === "overviewChartsButton") overviewCharts(); else if (button === "overviewBurgsButton") overviewBurgs(); else if (button === "overviewRiversButton") overviewRivers(); else if (button === "overviewMilitaryButton") overviewMilitary(); @@ -855,3 +856,8 @@ function viewCellDetails() { position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"} }); } + +async function overviewCharts() { + const Overview = await import("../dynamic/overview/charts-overview.js"); + Overview.open(); +} diff --git a/versioning.js b/versioning.js index ba8f7187..c5e0dd86 100644 --- a/versioning.js +++ b/versioning.js @@ -1,7 +1,7 @@ "use strict"; // version and caching control -const version = "1.86.07"; // generator version, update each time +const version = "1.87.00"; // generator version, update each time { document.title += " v" + version; From d30171b0544829e10bc6a69ee183036f6b1d36c7 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 17 Jun 2022 01:03:48 +0300 Subject: [PATCH 02/12] feat(charts): stack bar chart basic --- modules/dynamic/overview/charts-overview.js | 158 ++++++++++++-------- utils/functionUtils.js | 26 ++++ 2 files changed, 118 insertions(+), 66 deletions(-) create mode 100644 utils/functionUtils.js diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 3bee1ebf..5b37983a 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -1,3 +1,6 @@ +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"]; @@ -16,6 +19,11 @@ const quantizationMap = { cells: () => 1 }; +const sortingMap = { + value: (a, b) => b.value - a.value, + name: (a, b) => a.name.localeCompare(b.name) +}; + appendStyleSheet(); insertHtml(); @@ -78,26 +86,40 @@ function renderChart() { const plotBy = $plotBySelect.value; const groupBy = $groupBySelect.value; - const {array: entityArray, getName: getEntityName, cellsData: entityCells} = dataMap[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 chartData = entityArray - .filter(element => !element.removed) - .map(({i}) => { - const cells = pack.cells.i.filter(cellId => entityCells[cellId] === i); - const name = getEntityName(i); + const dataCollection = {}; + for (const cellId of pack.cells.i) { + if (filterWater && isWater(cellId)) continue; + const entityId = entityCells[cellId]; + const groupId = groupCells[cellId]; + const value = quantize(cellId); - return Array.from(cells).map(cellId => { - const group = getGroupName(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] += value; + } + + const chartData = Object.entries(dataCollection) + .map(([entityId, groupData]) => { + const name = getEntityName(entityId); + return Object.entries(groupData).map(([groupId, rawValue]) => { + const group = getGroupName(groupId); + const value = rn(rawValue); return {name, group, value}; }); }) .flat(); - console.log(chartData); - const chart = plot(chartData, {}); + const chartDataFiltered = filterZeroes ? chartData.filter(({value}) => value > 0) : chartData; + + const chart = plot(chartDataFiltered, {sorting}); byId("chartsOverview__svgContainer").appendChild(chart); } @@ -115,54 +137,58 @@ function updateSelectorOptions() { function plot( data, { - title, // given d in data, returns the title text marginTop = 30, // top margin, in pixels marginRight = 0, // right margin, in pixels marginBottom = 40, // bottom margin, in pixels marginLeft = 100, // left margin, in pixels - width = 2400, // outer width, in pixels - height = 400, // outer height, in pixels + width = 800, // outer width, in pixels xRange = [marginLeft, width - marginRight], // [xmin, xmax] - xPadding = 0.1, // amount of x-range to reserve to separate groups - yType = d3.scaleLinear, // type of y-scale - yRange = [height - marginBottom, marginTop], // [ymin, ymax] - zPadding = 0.05, // amount of x-range to reserve to separate bars - yFormat, // a format specifier string for the y-axis - yLabel, // a label for the y-axis - colors = d3.schemeCategory10 // array of colors + yPadding = 0.2, + xFormat, + xLabel = "Population (millions) →", + sorting } = {} ) { - const X = data.map(d => d.name); - const Y = data.map(d => d.value); + const X = data.map(d => d.value); + const Y = data.map(d => d.name); const Z = data.map(d => d.group); - const xDomain = new Set(X); - const yDomain = [0, d3.max(Y)]; + const yDomain = new Set(Y); // get from parent, already sorted const zDomain = new Set(Z); - // omit any data not present in both the x- and z-domain - const I = d3.range(X.length).filter(i => xDomain.has(X[i]) && zDomain.has(Z[i])); + // 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 xDomainArray = Array.from(xDomain); - const zDomainArray = Array.from(zDomain); + const height = yDomain.size * 25 + marginTop + marginBottom; + const yRange = [height - marginBottom, marginTop]; - // Construct scales, axes, and formats - const xScale = d3.scaleBand(xDomainArray, xRange).paddingInner(xPadding); - const xzScale = d3.scaleBand(zDomainArray, [0, xScale.bandwidth()]).padding(zPadding); - const yScale = yType(yDomain, yRange); - const zScale = d3.scaleOrdinal(zDomainArray, colors); - const xAxis = d3.axisBottom(xScale).tickSizeOuter(0); - const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat); + const offset = d3.stackOffsetDiverging; + const order = d3.stackOrderNone; - // Compute titles - if (title === undefined) { - const formatValue = yScale.tickFormat(100, yFormat); - title = i => `${X[i]}\n${Z[i]}\n${formatValue(Y[i])}`; - } else { - const O = d3.map(data, d => d); - const T = title; - title = i => T(O[i], i, data); - } + 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)}))); + + const xDomain = d3.extent(series.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 svg = d3 .create("svg") @@ -173,47 +199,48 @@ function plot( svg .append("g") - .attr("transform", `translate(${marginLeft},0)`) - .call(yAxis) + .attr("transform", `translate(0,${marginTop})`) + .call(xAxis) .call(g => g.select(".domain").remove()) .call(g => g .selectAll(".tick line") .clone() - .attr("x2", width - marginLeft - marginRight) + .attr("y2", height - marginTop - marginBottom) .attr("stroke-opacity", 0.1) ) .call(g => g .append("text") - .attr("x", -marginLeft) - .attr("y", 10) + .attr("x", width - marginRight) + .attr("y", -22) .attr("fill", "currentColor") - .attr("text-anchor", "start") - .text(yLabel) + .attr("text-anchor", "end") + .text(xLabel) ); const bar = svg .append("g") + .selectAll("g") + .data(series) + .join("g") + .attr("fill", ([{i}]) => color(Z[i])) .selectAll("rect") - .data(I) + .data(d => d.filter(d => d.i !== undefined)) .join("rect") - .attr("x", i => xScale(X[i]) + xzScale(Z[i])) - .attr("y", i => yScale(Y[i])) - .attr("width", xzScale.bandwidth()) - .attr("height", i => yScale(0) - yScale(Y[i])) - .attr("fill", i => zScale(Z[i])); + .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()); - if (title) bar.append("title").text(title); + bar.append("title").text(({i}) => title(i)); svg .append("g") - .attr("transform", `translate(0,${height - marginBottom})`) - .call(xAxis); + .attr("transform", `translate(${xScale(0)},0)`) + .call(yAxis); - const chart = Object.assign(svg.node(), {scales: {color: zScale}}); - console.log(chart); - return chart; + return Object.assign(svg.node(), {scales: {color}}); } // helper functions @@ -225,6 +252,5 @@ function getUrbanPopulation(cellId) { } function getRuralPopulation(cellId) { - const populationPoints = pack.cells.pop[cellId] * populationRate; - return populationPoints * populationRate; + return pack.cells.pop[cellId] * populationRate; } diff --git a/utils/functionUtils.js b/utils/functionUtils.js new file mode 100644 index 00000000..dd8ab50a --- /dev/null +++ b/utils/functionUtils.js @@ -0,0 +1,26 @@ +function identity(x) { + return x; +} + +export function rollup(values, reduce, ...keys) { + return nest(values, identity, reduce, keys); +} + +function nest(values, map, reduce, keys) { + return (function regroup(values, i) { + if (i >= keys.length) return reduce(values); + const groups = new Map(); + const keyof = keys[i++]; + let index = -1; + for (const value of values) { + const key = keyof(value, ++index, values); + const group = groups.get(key); + if (group) group.push(value); + else groups.set(key, [value]); + } + for (const [key, values] of groups) { + groups.set(key, regroup(values, i)); + } + return map(groups); + })(values, 0); +} From b00b9db3b599eaa6dd44f2ad599f0e56b0fda57f Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 19 Jun 2022 13:30:25 +0300 Subject: [PATCH 03/12] 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; +} From 60a43330a1abef746c0adddbb26294f319b1cb06 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 19 Jun 2022 15:20:29 +0300 Subject: [PATCH 04/12] feat(charts): correct sorting --- modules/dynamic/overview/charts-overview.js | 57 ++++++++++++++------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 9684c3ba..a126952b 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -178,7 +178,6 @@ function renderChart(event) { const noGrouping = groupBy === entity; const filterWater = true; - const filterZeroes = true; const {label: plotByLabel, stringify, quantize, formatTicks} = quantizationMap[plotBy]; const {label: entityLabel, getName: getEntityName, cellsData: entityCells} = entitiesMap[entity]; @@ -194,6 +193,8 @@ function renderChart(event) { }; const dataCollection = {}; + const groups = new Set(); + for (const cellId of pack.cells.i) { if (filterWater && isWater(cellId)) continue; const entityId = entityCells[cellId]; @@ -203,6 +204,15 @@ function renderChart(event) { if (!dataCollection[entityId]) dataCollection[entityId] = {[groupId]: value}; else if (!dataCollection[entityId][groupId]) dataCollection[entityId][groupId] = value; else dataCollection[entityId][groupId] += value; + + groups.add(groupId); + } + + // fill missing groups with 0 + for (const entityId in dataCollection) { + for (const groupId of groups) { + if (!dataCollection[entityId][groupId]) dataCollection[entityId][groupId] = 0; + } } const chartData = Object.entries(dataCollection) @@ -216,10 +226,9 @@ function renderChart(event) { }) .flat(); - const chartDataFiltered = filterZeroes ? chartData.filter(({value}) => value > 0) : chartData; const colors = getColors(); - const chart = plot(chartDataFiltered, {sorting, colors, formatTicks, tooltip}); + const chart = plot(chartData, {sorting, colors, formatTicks, tooltip}); insertChart(chart, title); byId("chartsOverview__charts").lastChild.scrollIntoView(); @@ -243,13 +252,13 @@ function plot( tooltip } = {} ) { - const X = data.map(d => d.value); - const Y = data.map(d => d.name); - const Z = data.map(d => d.group); + const sortedData = sortData(data, sorting); - 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 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 => X[i] > 0 && yDomain.has(Y[i]) && zDomain.has(Z[i])); @@ -372,17 +381,29 @@ 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))); +function sortData(data, sorting) { + if (sorting === "natural") return data; - 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; + 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); }); } - return array; + 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; } From 90b1a14bba7bd7451b1232553356de9f1b97a0b6 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 19 Jun 2022 16:02:44 +0300 Subject: [PATCH 05/12] feat(charts): add province and biome entities --- modules/dynamic/overview/charts-overview.js | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index a126952b..f3225d2c 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -4,24 +4,33 @@ import {stack} from "https://cdn.skypack.dev/d3-shape@3"; 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") + }, + provinces: { + label: "Province", + cellsData: pack.cells.province, + getName: nameGetter("provinces"), + getColors: colorsGetter("provinces") + }, + biomes: { + label: "Biome", + cellsData: pack.cells.biome, + getName: biomeNameGetter, + getColors: biomeColorsGetter } }; @@ -176,7 +185,6 @@ function renderChart(event) { const sorting = byId("chartsOverview__sortingSelect").value; const noGrouping = groupBy === entity; - const filterWater = true; const {label: plotByLabel, stringify, quantize, formatTicks} = quantizationMap[plotBy]; @@ -361,13 +369,23 @@ function updateDialog() { // config const NEUTRAL_COLOR = "#ccc"; +const EMPTY_NAME = "no"; // helper functions function nameGetter(entity) { - return i => pack[entity][i].name; + return i => pack[entity][i].name || EMPTY_NAME; } + function colorsGetter(entity) { - return () => Object.fromEntries(pack[entity].map(({name, color}) => [name, color || NEUTRAL_COLOR])); + 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) { From 9a9c8e700c76f6a72ebc2407bf72e5b9766845d1 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 19 Jun 2022 19:13:19 +0300 Subject: [PATCH 06/12] feat(charts): add quantization types --- modules/dynamic/overview/charts-overview.js | 203 +++++++++++++++++--- modules/ui/general.js | 44 +++-- utils/unitUtils.js | 33 ++-- 3 files changed, 217 insertions(+), 63 deletions(-) diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index f3225d2c..8c87e426 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -6,31 +6,36 @@ const entitiesMap = { label: "State", cellsData: pack.cells.state, getName: nameGetter("states"), - getColors: colorsGetter("states") + getColors: colorsGetter("states"), + landOnly: true }, cultures: { label: "Culture", cellsData: pack.cells.culture, getName: nameGetter("cultures"), - getColors: colorsGetter("cultures") + getColors: colorsGetter("cultures"), + landOnly: true }, religions: { label: "Religion", cellsData: pack.cells.religion, getName: nameGetter("religions"), - getColors: colorsGetter("religions") + getColors: colorsGetter("religions"), + landOnly: true }, provinces: { label: "Province", cellsData: pack.cells.province, getName: nameGetter("provinces"), - getColors: colorsGetter("provinces") + getColors: colorsGetter("provinces"), + landOnly: true }, biomes: { label: "Biome", cellsData: pack.cells.biome, getName: biomeNameGetter, - getColors: biomeColorsGetter + getColors: biomeColorsGetter, + landOnly: false } }; @@ -38,32 +43,155 @@ 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()} people` + 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()} people` + 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()} people` + 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()}` + 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()} cells` + 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 } }; @@ -181,14 +309,32 @@ function renderChart(event) { const entity = byId("chartsOverview__entitiesSelect").value; const plotBy = byId("chartsOverview__plotBySelect").value; - const groupBy = byId("chartsOverview__groupBySelect").value; + let groupBy = byId("chartsOverview__groupBySelect").value; const sorting = byId("chartsOverview__sortingSelect").value; - const noGrouping = groupBy === entity; - const filterWater = true; + const { + label: plotByLabel, + stringify, + quantize, + aggregate, + formatTicks, + stackable, + landOnly: plotByLandOnly + } = quantizationMap[plotBy]; - const {label: plotByLabel, stringify, quantize, formatTicks} = quantizationMap[plotBy]; - const {label: entityLabel, getName: getEntityName, cellsData: entityCells} = entitiesMap[entity]; + 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}`; @@ -204,31 +350,24 @@ function renderChart(event) { const groups = new Set(); for (const cellId of pack.cells.i) { - if (filterWater && isWater(cellId)) continue; + 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] += value; + 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); } - // fill missing groups with 0 - for (const entityId in dataCollection) { - for (const groupId of groups) { - if (!dataCollection[entityId][groupId]) dataCollection[entityId][groupId] = 0; - } - } - const chartData = Object.entries(dataCollection) .map(([entityId, groupData]) => { const name = getEntityName(entityId); - return Object.entries(groupData).map(([groupId, rawValue]) => { + return Object.entries(groupData).map(([groupId, values]) => { const group = getGroupName(groupId); - const value = rn(rawValue); + const value = aggregate(values); return {name, group, value}; }); }) @@ -269,7 +408,7 @@ function plot( const yDomain = new Set(Y); const zDomain = new Set(Z); - const I = d3.range(X.length).filter(i => X[i] > 0 && yDomain.has(Y[i]) && zDomain.has(Z[i])); + const I = d3.range(X.length).filter(i => yDomain.has(Y[i]) && zDomain.has(Z[i])); const height = yDomain.size * 25 + marginTop + marginBottom; const yRange = [height - marginBottom, marginTop]; @@ -282,8 +421,8 @@ function plot( .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)})); + 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}; }); @@ -324,7 +463,7 @@ function plot( .join("g") .attr("fill", d => colors[d.key]) .selectAll("rect") - .data(d => d.data) + .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])) diff --git a/modules/ui/general.js b/modules/ui/general.js index ad46d184..fa030418 100644 --- a/modules/ui/general.js +++ b/modules/ui/general.js @@ -121,7 +121,11 @@ function showMapTooltip(point, e, i, g) { if (group === "emblems" && e.target.tagName === "use") { const parent = e.target.parentNode; const [g, type] = - parent.id === "burgEmblems" ? [pack.burgs, "burg"] : parent.id === "provinceEmblems" ? [pack.provinces, "province"] : [pack.states, "state"]; + parent.id === "burgEmblems" + ? [pack.burgs, "burg"] + : parent.id === "provinceEmblems" + ? [pack.provinces, "province"] + : [pack.states, "state"]; const i = +e.target.dataset.i; if (event.shiftKey) highlightEmblemElement(type, g[i]); @@ -161,8 +165,10 @@ function showMapTooltip(point, e, i, g) { if (group === "ruler") { const tag = e.target.tagName; const className = e.target.getAttribute("class"); - if (tag === "circle" && className === "edge") return tip("Drag to adjust. Hold Ctrl and drag to add a point. Click to remove the point"); - if (tag === "circle" && className === "control") return tip("Drag to adjust. Hold Shift and drag to keep axial direction. Click to remove the point"); + if (tag === "circle" && className === "edge") + return tip("Drag to adjust. Hold Ctrl and drag to add a point. Click to remove the point"); + if (tag === "circle" && className === "control") + return tip("Drag to adjust. Hold Shift and drag to keep axial direction. Click to remove the point"); if (tag === "circle") return tip("Drag to adjust the measurer"); if (tag === "polyline") return tip("Click on drag to add a control point"); if (tag === "path") return tip("Drag to move the measurer"); @@ -248,10 +254,19 @@ function updateCellInfo(point, i, g) { infoTemp.innerHTML = convertTemperature(grid.cells.temp[g]); infoPrec.innerHTML = cells.h[i] >= 20 ? getFriendlyPrecipitation(i) : "n/a"; infoRiver.innerHTML = cells.h[i] >= 20 && cells.r[i] ? getRiverInfo(cells.r[i]) : "no"; - infoState.innerHTML = cells.h[i] >= 20 ? (cells.state[i] ? `${pack.states[cells.state[i]].fullName} (${cells.state[i]})` : "neutral lands (0)") : "no"; - infoProvince.innerHTML = cells.province[i] ? `${pack.provinces[cells.province[i]].fullName} (${cells.province[i]})` : "no"; + infoState.innerHTML = + cells.h[i] >= 20 + ? cells.state[i] + ? `${pack.states[cells.state[i]].fullName} (${cells.state[i]})` + : "neutral lands (0)" + : "no"; + infoProvince.innerHTML = cells.province[i] + ? `${pack.provinces[cells.province[i]].fullName} (${cells.province[i]})` + : "no"; infoCulture.innerHTML = cells.culture[i] ? `${pack.cultures[cells.culture[i]].name} (${cells.culture[i]})` : "no"; - infoReligion.innerHTML = cells.religion[i] ? `${pack.religions[cells.religion[i]].name} (${cells.religion[i]})` : "no"; + infoReligion.innerHTML = cells.religion[i] + ? `${pack.religions[cells.religion[i]].name} (${cells.religion[i]})` + : "no"; infoPopulation.innerHTML = getFriendlyPopulation(i); infoBurg.innerHTML = cells.burg[i] ? pack.burgs[cells.burg[i]].name + " (" + cells.burg[i] + ")" : "no"; infoFeature.innerHTML = f ? pack.features[f].group + " (" + f + ")" : "n/a"; @@ -300,8 +315,7 @@ function getFriendlyHeight([x, y]) { function getHeight(h, abs) { const unit = heightUnit.value; let unitRatio = 3.281; // default calculations are in feet - if (unit === "m") unitRatio = 1; - // if meter + if (unit === "m") unitRatio = 1; // if meter else if (unit === "f") unitRatio = 0.5468; // if fathom let height = -990; @@ -312,10 +326,14 @@ function getHeight(h, abs) { return rn(height * unitRatio) + " " + unit; } +function getPrecipitation(prec) { + return prec * 100 + " mm"; +} + // get user-friendly (real-world) precipitation value from map data function getFriendlyPrecipitation(i) { const prec = grid.cells.prec[pack.cells.g[i]]; - return prec * 100 + " mm"; + return getPrecipitation(prec); } function getRiverInfo(id) { @@ -399,7 +417,8 @@ function highlightEmblemElement(type, el) { // assign lock behavior document.querySelectorAll("[data-locked]").forEach(function (e) { e.addEventListener("mouseover", function (event) { - if (this.className === "icon-lock") tip("Click to unlock the option and allow it to be randomized on new map generation"); + if (this.className === "icon-lock") + tip("Click to unlock the option and allow it to be randomized on new map generation"); else tip("Click to lock the option and always use the current value on new map generation"); event.stopPropagation(); }); @@ -476,7 +495,10 @@ function showInfo() { const Patreon = link("https://www.patreon.com/azgaar", "Patreon"); const Armoria = link("https://azgaar.github.io/Armoria", "Armoria"); - const QuickStart = link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Quick-Start-Tutorial", "Quick start tutorial"); + const QuickStart = link( + "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Quick-Start-Tutorial", + "Quick start tutorial" + ); const QAA = link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Q&A", "Q&A page"); const VideoTutorial = link("https://youtube.com/playlist?list=PLtgiuDC8iVR2gIG8zMTRn7T_L0arl9h1C", "Video tutorial"); diff --git a/utils/unitUtils.js b/utils/unitUtils.js index 141fa432..a9a933df 100644 --- a/utils/unitUtils.js +++ b/utils/unitUtils.js @@ -2,27 +2,20 @@ // FMG utils related to units // conver temperature from °C to other scales +const temperatureConversionMap = { + "°C": temp => rn(temp) + "°C", + "°F": temp => rn((temp * 9) / 5 + 32) + "°F", + K: temp => rn(temp + 273.15) + "K", + "°R": temp => rn(((temp + 273.15) * 9) / 5) + "°R", + "°De": temp => rn(((100 - temp) * 3) / 2) + "°De", + "°N": temp => rn((temp * 33) / 100) + "°N", + "°Ré": temp => rn((temp * 4) / 5) + "°Ré", + "°Rø": temp => rn((temp * 21) / 40 + 7.5) + "°Rø" +}; + function convertTemperature(temp) { - switch (temperatureScale.value) { - case "°C": - return rn(temp) + "°C"; - case "°F": - return rn((temp * 9) / 5 + 32) + "°F"; - case "K": - return rn(temp + 273.15) + "K"; - case "°R": - return rn(((temp + 273.15) * 9) / 5) + "°R"; - case "°De": - return rn(((100 - temp) * 3) / 2) + "°De"; - case "°N": - return rn((temp * 33) / 100) + "°N"; - case "°Ré": - return rn((temp * 4) / 5) + "°Ré"; - case "°Rø": - return rn((temp * 21) / 40 + 7.5) + "°Rø"; - default: - return rn(temp) + "°C"; - } + const scale = temperatureScale.value || "°C"; + return temperatureConversionMap[scale](temp); } // corvent number to short string with SI postfix From 4a50d89b41e815e3b3edfa7f4dfbf339399e3879 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Mon, 20 Jun 2022 02:10:49 +0300 Subject: [PATCH 07/12] feat(charts): control buttons and legend base --- modules/dynamic/heightmap-selection.js | 5 +- modules/dynamic/hierarchy-tree.js | 5 +- modules/dynamic/overview/charts-overview.js | 126 +++++++++++++------- 3 files changed, 87 insertions(+), 49 deletions(-) diff --git a/modules/dynamic/heightmap-selection.js b/modules/dynamic/heightmap-selection.js index 5ca94e61..9b78d118 100644 --- a/modules/dynamic/heightmap-selection.js +++ b/modules/dynamic/heightmap-selection.js @@ -42,7 +42,8 @@ export function open() { } function appendStyleSheet() { - const styles = /* css */ ` + const style = document.createElement("style"); + style.textContent = /* css */ ` div.dialog > div.heightmap-selection { width: 70vw; height: 70vh; @@ -145,8 +146,6 @@ function appendStyleSheet() { } `; - const style = document.createElement("style"); - style.appendChild(document.createTextNode(styles)); document.head.appendChild(style); } diff --git a/modules/dynamic/hierarchy-tree.js b/modules/dynamic/hierarchy-tree.js index e3f06dde..95093790 100644 --- a/modules/dynamic/hierarchy-tree.js +++ b/modules/dynamic/hierarchy-tree.js @@ -62,7 +62,8 @@ export function open(props) { } function appendStyleSheet() { - const styles = /* css */ ` + const style = document.createElement("style"); + style.textContent = /* css */ ` #hierarchyTree_selectedOrigins > button { margin: 0 2px; } @@ -122,8 +123,6 @@ function appendStyleSheet() { } `; - const style = document.createElement("style"); - style.appendChild(document.createTextNode(styles)); document.head.appendChild(style); } diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 8c87e426..96bfcaf5 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -196,7 +196,6 @@ const quantizationMap = { }; appendStyleSheet(); - insertHtml(); addListeners(); changeViewColumns(); @@ -204,12 +203,12 @@ changeViewColumns(); export function open() { const charts = byId("chartsOverview__charts").childElementCount; if (!charts) renderChart(); - - $("#chartsOverview").dialog({title: "Data Charts"}); + else $("#chartsOverview").dialog({title: "Data Charts"}); } function appendStyleSheet() { - const styles = /* css */ ` + const style = document.createElement("style"); + style.textContent = /* css */ ` #chartsOverview { max-width: 90vw !important; max-height: 90vh !important; @@ -237,17 +236,12 @@ function appendStyleSheet() { #chartsOverview__charts figcaption { font-size: 1.2em; - margin-left: 4%; - } - - .chartsOverview__bars { - stroke: #666; - stroke-width: 0.5; + margin: 0 1% 0 4%; + display: grid; + grid-template-columns: 1fr auto; } `; - const style = document.createElement("style"); - style.appendChild(document.createTextNode(styles)); document.head.appendChild(style); } @@ -383,22 +377,7 @@ function renderChart(event) { } // based on observablehq.com/@d3/stacked-horizontal-bar-chart -function plot( - data, - { - marginTop = 30, // top 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, - sorting, - colors, - formatTicks, - tooltip - } = {} -) { +function plot(data, {sorting, colors, formatTicks, tooltip}) { const sortedData = sortData(data, sorting); const X = sortedData.map(d => d.value); @@ -407,11 +386,10 @@ function plot( 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 + marginTop + marginBottom; - const yRange = [height - marginBottom, marginTop]; + 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]]); @@ -429,21 +407,21 @@ function plot( 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 yScale = d3.scaleBand(Array.from(yDomain), yRange).paddingInner(Y_PADDING); - const xAxis = d3.axisTop(xScale).ticks(width / 80, null); + const xAxis = d3.axisTop(xScale).ticks(WIDTH / 80, null); const yAxis = d3.axisLeft(yScale).tickSizeOuter(0); const svg = d3 .create("svg") - .attr("width", width) - .attr("height", height) - .attr("viewBox", [0, 0, width, height]) + .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,${marginTop})`) + .attr("transform", `translate(0,${MARGIN.top})`) .call(xAxis) .call(g => g.select(".domain").remove()) .call(g => g.selectAll("text").text(d => formatTicks(d))) @@ -451,13 +429,14 @@ function plot( g .selectAll(".tick line") .clone() - .attr("y2", height - marginTop - marginBottom) + .attr("y2", height - MARGIN.top - MARGIN.bottom) .attr("stroke-opacity", 0.1) ); const bar = svg .append("g") - .attr("class", "chartsOverview__bars") + .attr("stroke", "#666") + .attr("stroke-width", 0.5) .selectAll("g") .data(series) .join("g") @@ -477,6 +456,37 @@ function plot( .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(); } @@ -487,12 +497,32 @@ function insertChart(chart, title) { const $caption = document.createElement("figcaption"); const figureNo = $chartContainer.childElementCount + 1; - $caption.innerHTML = `Figure ${figureNo}. ${title}`; + $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() { @@ -503,14 +533,24 @@ function changeViewColumns() { } function updateDialog() { - $("#chartsOverview").dialog({position: {my: "center", at: "center", of: window}}); + $("#chartsOverview").dialog({position: {my: "center", at: "center", of: "svg"}}); } // config const NEUTRAL_COLOR = "#ccc"; const EMPTY_NAME = "no"; -// helper functions +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; } From bc6ef813e95212aca04a69b148df2ad075052a32 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Tue, 21 Jun 2022 00:22:55 +0300 Subject: [PATCH 08/12] feat(charts): legend --- modules/dynamic/overview/charts-overview.js | 71 +++++++++++++-------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 96bfcaf5..012e69de 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -388,8 +388,16 @@ function plot(data, {sorting, colors, formatTicks, tooltip}) { 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 entities = Array.from(yDomain); + const groups = Array.from(zDomain); + + const yScaleMinWidth = getTextMinWidth(entities); + const legendRows = calculateLegendRows(groups); + + const margin = {top: 30, right: 10, bottom: legendRows * 20 + 10, left: yScaleMinWidth}; + const xRange = [margin.left, WIDTH - margin.right]; + 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]]); @@ -407,7 +415,7 @@ function plot(data, {sorting, colors, formatTicks, tooltip}) { 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 yScale = d3.scaleBand(entities, yRange).paddingInner(Y_PADDING); const xAxis = d3.axisTop(xScale).ticks(WIDTH / 80, null); const yAxis = d3.axisLeft(yScale).tickSizeOuter(0); @@ -421,7 +429,7 @@ function plot(data, {sorting, colors, formatTicks, tooltip}) { svg .append("g") - .attr("transform", `translate(0,${MARGIN.top})`) + .attr("transform", `translate(0,${margin.top})`) .call(xAxis) .call(g => g.select(".domain").remove()) .call(g => g.selectAll("text").text(d => formatTicks(d))) @@ -429,7 +437,7 @@ function plot(data, {sorting, colors, formatTicks, tooltip}) { g .selectAll(".tick line") .clone() - .attr("y2", height - MARGIN.top - MARGIN.bottom) + .attr("y2", height - margin.top - margin.bottom) .attr("stroke-opacity", 0.1) ); @@ -456,40 +464,57 @@ function plot(data, {sorting, colors, formatTicks, tooltip}) { .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 rowElements = Math.ceil(groups.length / legendRows); const columnWidth = WIDTH / (rowElements + 0.5); - const rowHeight = 20; + + const ROW_HEIGHT = 20; + const LABEL_GAP = 10; + + const getLegendX = (d, i) => (i % rowElements) * columnWidth; + const getLegendLabelX = (d, i) => getLegendX(d, i) + LABEL_GAP; + const getLegendY = (d, i) => Math.floor(i / rowElements) * ROW_HEIGHT; const legend = svg .append("g") + .attr("stroke", "#666") + .attr("stroke-width", 0.5) .attr("dominant-baseline", "central") - .attr("transform", `translate(${MARGIN.left},${height - MARGIN.bottom + 15})`); + .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) + .join("rect") + .attr("x", getLegendX) + .attr("y", getLegendY) + .attr("width", 10) + .attr("height", 10) + .attr("transform", "translate(-5, -5)") .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) + .attr("x", getLegendLabelX) + .attr("y", getLegendY) .text(d => d); return svg.node(); } +const RESERVED_PX_PER_CHAR = 8; +function getTextMinWidth(entities) { + return d3.max(entities.map(name => name.length)) * RESERVED_PX_PER_CHAR; +} + +function calculateLegendRows(groups) { + const minWidth = getTextMinWidth(groups); + const maxInRow = Math.floor(WIDTH / minWidth); + const legendRows = Math.ceil(groups.length / maxInRow); + return legendRows; +} + function insertChart(chart, title) { const $chartContainer = byId("chartsOverview__charts"); @@ -540,15 +565,7 @@ function updateDialog() { 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) { From 76d3b7ebcb14579266b706de10b1bc5a0f95d611 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 23 Jun 2022 02:17:59 +0300 Subject: [PATCH 09/12] feat(charts): normalized stacked bars --- modules/dynamic/overview/charts-overview.js | 110 +++++++++++++------- utils/functionUtils.js | 8 ++ 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 012e69de..3418426e 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -1,4 +1,4 @@ -import {rollup} from "../../../utils/functionUtils.js"; +import {rollup, rollups} from "../../../utils/functionUtils.js"; import {stack} from "https://cdn.skypack.dev/d3-shape@3"; const entitiesMap = { @@ -195,6 +195,11 @@ const quantizationMap = { } }; +const plotTypeMap = { + stackedBar: {offset: d3.stackOffsetDiverging}, + normalizedStackedBar: {offset: d3.stackOffsetExpand, formatX: value => rn(value * 100) + "%"} +}; + appendStyleSheet(); insertHtml(); addListeners(); @@ -219,9 +224,20 @@ function appendStyleSheet() { #chartsOverview__form { font-size: 1.1em; - margin: 0.3em; - display: flex; - justify-content: space-between; + margin: 0.3em 0; + display: grid; + grid-template-columns: auto auto; + grid-gap: 0.3em; + align-items: start; + justify-items: end; + } + + @media (max-width: 600px) { + #chartsOverview__form { + font-size: 1em; + grid-template-columns: 1fr; + justify-items: normal; + } } #chartsOverview__charts { @@ -256,22 +272,32 @@ function insertHtml() {
+ - by - + - grouped by - + - sorted - +
+ Type + + Columns ${createOptions(entities)} + -