diff --git a/index.html b/index.html index d8e4a786..1ff05540 100644 --- a/index.html +++ b/index.html @@ -108,14 +108,9 @@ } - - - + + + Click to overview:

+ @@ -7779,89 +7781,89 @@ - + - + - - + + - + - - + + - - + + - + - + - - - + + + - - - + + + - - - - - - + + + + + + - + - - - + + + - - - - - - - - - + + + + + + + + + - - + + - - - - + + + + diff --git a/modules/dynamic/editors/cultures-editor.js b/modules/dynamic/editors/cultures-editor.js index 9c651acc..4b5bc788 100644 --- a/modules/dynamic/editors/cultures-editor.js +++ b/modules/dynamic/editors/cultures-editor.js @@ -630,7 +630,7 @@ function togglePercentageMode() { async function showHierarchy() { if (customization) return; - const HeirarchyTree = await import("../hierarchy-tree.js?v=19062022"); + const HeirarchyTree = await import("../hierarchy-tree.js?v=1.87.00"); const getDescription = culture => { const {name, type, rural, urban} = culture; diff --git a/modules/dynamic/editors/religions-editor.js b/modules/dynamic/editors/religions-editor.js index 53d625fe..ebd75e18 100644 --- a/modules/dynamic/editors/religions-editor.js +++ b/modules/dynamic/editors/religions-editor.js @@ -533,7 +533,7 @@ function togglePercentageMode() { async function showHierarchy() { if (customization) return; - const HeirarchyTree = await import("../hierarchy-tree.js?v=19062022"); + const HeirarchyTree = await import("../hierarchy-tree.js?v=1.87.00"); const getDescription = religion => { const {name, type, form, rural, urban} = religion; 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 dc161e43..6cd25f0e 100644 --- a/modules/dynamic/hierarchy-tree.js +++ b/modules/dynamic/hierarchy-tree.js @@ -62,7 +62,11 @@ export function open(props) { } function appendStyleSheet() { - const styles = /* css */ ` + const style = document.createElement("style"); + style.textContent = /* css */ ` + #hierarchyTree_selectedOrigins > button { + margin: 0 2px; + } #hierarchyTree { display: flex; @@ -74,10 +78,6 @@ function appendStyleSheet() { height: 100%; } - #hierarchyTree_selectedOrigins > button { - margin: 0 2px; - } - .hierarchyTree_selectedOrigins { margin-right: 15px; } @@ -141,8 +141,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 new file mode 100644 index 00000000..3fe5464e --- /dev/null +++ b/modules/dynamic/overview/charts-overview.js @@ -0,0 +1,665 @@ +import {rollups} from "../../../utils/functionUtils.js"; + +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 + } +}; + +const plotTypeMap = { + stackedBar: {offset: d3.stackOffsetDiverging}, + normalizedStackedBar: {offset: d3.stackOffsetExpand, formatX: value => rn(value * 100) + "%"} +}; + +appendStyleSheet(); +insertHtml(); +addListeners(); +changeViewColumns(); + +export function open() { + const charts = byId("chartsOverview__charts").childElementCount; + if (!charts) renderChart(); + $("#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 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 { + 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 */ `
+
+
+ + + + + + + + + +
+
+ Type + + + 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() { + 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 type = byId("chartsOverview__chartType").value; + + const { + label: plotByLabel, + stringify, + quantize, + aggregate, + formatTicks, + stackable, + landOnly: plotByLandOnly + } = quantizationMap[plotBy]; + + if (!stackable && groupBy !== entity) { + tip(`Grouping is not supported for ${plotByLabel}`, 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, percentage) => { + const entityTip = `${entityLabel}: ${entity}`; + const groupTip = noGrouping ? "" : `${groupLabel}: ${group}`; + let valueTip = `${plotByLabel}: ${stringify(value)}`; + if (!noGrouping) valueTip += ` (${rn(percentage * 100)}%)`; + return [entityTip, groupTip, valueTip].filter(Boolean); + }; + + 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 {offset, formatX = formatTicks} = plotTypeMap[type]; + + const chart = createStackedBarChart(chartData, {sorting, colors, tooltip, offset, formatX}); + insertChart(chart, title); + + byId("chartsOverview__charts").lastChild.scrollIntoView(); + updateDialog(); +} + +// based on observablehq.com/@d3/stacked-horizontal-bar-chart +function createStackedBarChart(data, {sorting, colors, tooltip, offset, formatX}) { + 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 entities = Array.from(yDomain); + const groups = Array.from(zDomain); + + const yScaleMinWidth = getTextMinWidth(entities); + const legendRows = calculateLegendRows(groups, WIDTH - yScaleMinWidth - 15); + + const margin = {top: 30, right: 15, 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 = rollups(...[I, ([i]) => i, i => Y[i], i => Z[i]]); + + const series = d3 + .stack() + .keys(groups) + .value(([, I], z) => X[new Map(I).get(z)]) + .order(d3.stackOrderNone) + .offset(offset)(rolled) + .map(s => { + const defined = s.filter(d => !isNaN(d[1])); + const data = defined.map(d => Object.assign(d, {i: new Map(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(entities, 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 => formatX(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()); + + const totalZ = Object.fromEntries( + rollups(...[I, ([i]) => i, i => Y[i], i => X[i]]).map(([y, yz]) => [y, d3.sum(yz, yz => yz[0])]) + ); + const getTooltip = ({i}) => tooltip(Y[i], Z[i], X[i], X[i] / totalZ[Y[i]]); + + bar.append("title").text(d => getTooltip(d).join("\r\n")); + bar.on("mouseover", d => tip(getTooltip(d).join(". "))); + + svg + .append("g") + .attr("transform", `translate(${xScale(0)},0)`) + .call(yAxis); + + const rowElements = Math.ceil(groups.length / legendRows); + const columnWidth = WIDTH / (rowElements + 0.5); + + const ROW_HEIGHT = 20; + + 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})`); + + legend + .selectAll("circle") + .data(groups) + .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", getLegendLabelX) + .attr("y", getLegendY) + .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 WIDTH = 800; +const Y_PADDING = 0.2; + +const RESERVED_PX_PER_CHAR = 7; +const LABEL_GAP = 10; + +function getTextMinWidth(entities) { + return d3.max(entities.map(name => name.length)) * RESERVED_PX_PER_CHAR; +} + +function calculateLegendRows(groups, availableWidth) { + const minWidth = LABEL_GAP + getTextMinWidth(groups); + const maxInRow = Math.floor(availableWidth / minWidth); + const legendRows = Math.ceil(groups.length / maxInRow); + return legendRows; +} + +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; +} diff --git a/modules/ui/editors.js b/modules/ui/editors.js index af3e3028..403a77a2 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -1180,12 +1180,12 @@ async function editStates() { async function editCultures() { if (customization) return; - const Editor = await import("../dynamic/editors/cultures-editor.js?v=19062022"); + const Editor = await import("../dynamic/editors/cultures-editor.js?v=1.87.00"); Editor.open(); } async function editReligions() { if (customization) return; - const Editor = await import("../dynamic/editors/religions-editor.js?v=19062022"); + const Editor = await import("../dynamic/editors/religions-editor.js?v=1.87.00"); Editor.open(); } 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/modules/ui/hotkeys.js b/modules/ui/hotkeys.js index 6cba97a0..d8fcaaf8 100644 --- a/modules/ui/hotkeys.js +++ b/modules/ui/hotkeys.js @@ -48,6 +48,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(); diff --git a/modules/ui/options.js b/modules/ui/options.js index 48ddf7bd..0683a694 100644 --- a/modules/ui/options.js +++ b/modules/ui/options.js @@ -635,7 +635,7 @@ function changeEra() { } async function openTemplateSelectionDialog() { - const HeightmapSelectionDialog = await import("../dynamic/heightmap-selection.js?v=290520222"); + const HeightmapSelectionDialog = await import("../dynamic/heightmap-selection.js?v=1.87.00"); HeightmapSelectionDialog.open(); } 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/utils/functionUtils.js b/utils/functionUtils.js new file mode 100644 index 00000000..845673a8 --- /dev/null +++ b/utils/functionUtils.js @@ -0,0 +1,25 @@ +// extracted d3 code to bypass version conflicts +// https://github.com/d3/d3-array/blob/main/src/group.js + +export function rollups(values, reduce, ...keys) { + return nest(values, Array.from, 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); +} 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 diff --git a/versioning.js b/versioning.js index 4e7f3589..f725265a 100644 --- a/versioning.js +++ b/versioning.js @@ -1,7 +1,7 @@ "use strict"; // version and caching control -const version = "1.86.10"; // generator version, update each time +const version = "1.87.00"; // generator version, update each time { document.title += " v" + version; @@ -28,6 +28,7 @@ const version = "1.86.10"; // generator version, update each time

Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.