mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-18 10:01:23 +01:00
refactor(es modules): move all files to src, try vite 3.0
This commit is contained in:
parent
4feed39d5c
commit
0d05e1b250
119 changed files with 8218 additions and 139 deletions
635
src/modules/dynamic/auto-update.js
Normal file
635
src/modules/dynamic/auto-update.js
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
import {findCell} from "/src/utils/graphUtils";
|
||||
import {rn} from "/src/utils/numberUtils";
|
||||
import {rand, P, rw} from "/src/utils/probabilityUtils";
|
||||
import {parseTransform} from "/src/utils/stringUtils";
|
||||
|
||||
// update old .map version to the current one
|
||||
export function resolveVersionConflicts(version) {
|
||||
if (version < 1) {
|
||||
// v1.0 added a new religions layer
|
||||
relig = viewbox.insert("g", "#terrain").attr("id", "relig");
|
||||
Religions.generate();
|
||||
|
||||
// v1.0 added a legend box
|
||||
legend = svg.append("g").attr("id", "legend");
|
||||
legend
|
||||
.attr("font-family", "Almendra SC")
|
||||
.attr("font-size", 13)
|
||||
.attr("data-size", 13)
|
||||
.attr("data-x", 99)
|
||||
.attr("data-y", 93)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("stroke", "#812929")
|
||||
.attr("stroke-dasharray", "0 4 10 4")
|
||||
.attr("stroke-linecap", "round");
|
||||
|
||||
// v1.0 separated drawBorders fron drawStates()
|
||||
stateBorders = borders.append("g").attr("id", "stateBorders");
|
||||
provinceBorders = borders.append("g").attr("id", "provinceBorders");
|
||||
borders
|
||||
.attr("opacity", null)
|
||||
.attr("stroke", null)
|
||||
.attr("stroke-width", null)
|
||||
.attr("stroke-dasharray", null)
|
||||
.attr("stroke-linecap", null)
|
||||
.attr("filter", null);
|
||||
stateBorders
|
||||
.attr("opacity", 0.8)
|
||||
.attr("stroke", "#56566d")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "2")
|
||||
.attr("stroke-linecap", "butt");
|
||||
provinceBorders
|
||||
.attr("opacity", 0.8)
|
||||
.attr("stroke", "#56566d")
|
||||
.attr("stroke-width", 0.5)
|
||||
.attr("stroke-dasharray", "1")
|
||||
.attr("stroke-linecap", "butt");
|
||||
|
||||
// v1.0 added state relations, provinces, forms and full names
|
||||
provs = viewbox.insert("g", "#borders").attr("id", "provs").attr("opacity", 0.6);
|
||||
BurgsAndStates.collectStatistics();
|
||||
BurgsAndStates.generateCampaigns();
|
||||
BurgsAndStates.generateDiplomacy();
|
||||
BurgsAndStates.defineStateForms();
|
||||
drawStates();
|
||||
BurgsAndStates.generateProvinces();
|
||||
drawBorders();
|
||||
if (!layerIsOn("toggleBorders")) $("#borders").fadeOut();
|
||||
if (!layerIsOn("toggleStates")) regions.attr("display", "none").selectAll("path").remove();
|
||||
|
||||
// v1.0 added zones layer
|
||||
zones = viewbox.insert("g", "#borders").attr("id", "zones").attr("display", "none");
|
||||
zones
|
||||
.attr("opacity", 0.6)
|
||||
.attr("stroke", null)
|
||||
.attr("stroke-width", 0)
|
||||
.attr("stroke-dasharray", null)
|
||||
.attr("stroke-linecap", "butt");
|
||||
addZones();
|
||||
if (!markers.selectAll("*").size()) {
|
||||
Markers.generate();
|
||||
turnButtonOn("toggleMarkers");
|
||||
}
|
||||
|
||||
// v1.0 add fogging layer (state focus)
|
||||
fogging = viewbox
|
||||
.insert("g", "#ruler")
|
||||
.attr("id", "fogging-cont")
|
||||
.attr("mask", "url(#fog)")
|
||||
.append("g")
|
||||
.attr("id", "fogging")
|
||||
.style("display", "none");
|
||||
fogging.append("rect").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%");
|
||||
defs
|
||||
.append("mask")
|
||||
.attr("id", "fog")
|
||||
.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("fill", "white");
|
||||
|
||||
// v1.0 changes states opacity bask to regions level
|
||||
if (statesBody.attr("opacity")) {
|
||||
regions.attr("opacity", statesBody.attr("opacity"));
|
||||
statesBody.attr("opacity", null);
|
||||
}
|
||||
|
||||
// v1.0 changed labels to multi-lined
|
||||
labels.selectAll("textPath").each(function () {
|
||||
const text = this.textContent;
|
||||
const shift = this.getComputedTextLength() / -1.5;
|
||||
this.innerHTML = /* html */ `<tspan x="${shift}">${text}</tspan>`;
|
||||
});
|
||||
|
||||
// v1.0 added new biome - Wetland
|
||||
biomesData.name.push("Wetland");
|
||||
biomesData.color.push("#0b9131");
|
||||
biomesData.habitability.push(12);
|
||||
}
|
||||
|
||||
if (version < 1.1) {
|
||||
// v1.0 initial code had a bug with religion layer id
|
||||
if (!relig.size()) relig = viewbox.insert("g", "#terrain").attr("id", "relig");
|
||||
|
||||
// v1.0 initially has Sympathy status then relaced with Friendly
|
||||
for (const s of pack.states) {
|
||||
if (!s.diplomacy) continue;
|
||||
s.diplomacy = s.diplomacy.map(r => (r === "Sympathy" ? "Friendly" : r));
|
||||
}
|
||||
|
||||
// labels should be toggled via style attribute, so remove display attribute
|
||||
labels.attr("display", null);
|
||||
|
||||
// v1.0 added religions heirarchy tree
|
||||
if (pack.religions[1] && !pack.religions[1].code) {
|
||||
pack.religions
|
||||
.filter(r => r.i)
|
||||
.forEach(r => {
|
||||
r.origin = 0;
|
||||
r.code = r.name.slice(0, 2);
|
||||
});
|
||||
}
|
||||
|
||||
if (!document.getElementById("freshwater")) {
|
||||
lakes.append("g").attr("id", "freshwater");
|
||||
lakes
|
||||
.select("#freshwater")
|
||||
.attr("opacity", 0.5)
|
||||
.attr("fill", "#a6c1fd")
|
||||
.attr("stroke", "#5f799d")
|
||||
.attr("stroke-width", 0.7)
|
||||
.attr("filter", null);
|
||||
}
|
||||
|
||||
if (!document.getElementById("salt")) {
|
||||
lakes.append("g").attr("id", "salt");
|
||||
lakes
|
||||
.select("#salt")
|
||||
.attr("opacity", 0.5)
|
||||
.attr("fill", "#409b8a")
|
||||
.attr("stroke", "#388985")
|
||||
.attr("stroke-width", 0.7)
|
||||
.attr("filter", null);
|
||||
}
|
||||
|
||||
// v1.1 added new lake and coast groups
|
||||
if (!document.getElementById("sinkhole")) {
|
||||
lakes.append("g").attr("id", "sinkhole");
|
||||
lakes.append("g").attr("id", "frozen");
|
||||
lakes.append("g").attr("id", "lava");
|
||||
lakes
|
||||
.select("#sinkhole")
|
||||
.attr("opacity", 1)
|
||||
.attr("fill", "#5bc9fd")
|
||||
.attr("stroke", "#53a3b0")
|
||||
.attr("stroke-width", 0.7)
|
||||
.attr("filter", null);
|
||||
lakes
|
||||
.select("#frozen")
|
||||
.attr("opacity", 0.95)
|
||||
.attr("fill", "#cdd4e7")
|
||||
.attr("stroke", "#cfe0eb")
|
||||
.attr("stroke-width", 0)
|
||||
.attr("filter", null);
|
||||
lakes
|
||||
.select("#lava")
|
||||
.attr("opacity", 0.7)
|
||||
.attr("fill", "#90270d")
|
||||
.attr("stroke", "#f93e0c")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("filter", "url(#crumpled)");
|
||||
|
||||
coastline.append("g").attr("id", "sea_island");
|
||||
coastline.append("g").attr("id", "lake_island");
|
||||
coastline
|
||||
.select("#sea_island")
|
||||
.attr("opacity", 0.5)
|
||||
.attr("stroke", "#1f3846")
|
||||
.attr("stroke-width", 0.7)
|
||||
.attr("filter", "url(#dropShadow)");
|
||||
coastline
|
||||
.select("#lake_island")
|
||||
.attr("opacity", 1)
|
||||
.attr("stroke", "#7c8eaf")
|
||||
.attr("stroke-width", 0.35)
|
||||
.attr("filter", null);
|
||||
}
|
||||
|
||||
// v1.1 features stores more data
|
||||
defs.select("#land").selectAll("path").remove();
|
||||
defs.select("#water").selectAll("path").remove();
|
||||
coastline.selectAll("path").remove();
|
||||
lakes.selectAll("path").remove();
|
||||
drawCoastline();
|
||||
}
|
||||
|
||||
if (version < 1.11) {
|
||||
// v1.11 added new attributes
|
||||
terrs.attr("scheme", "bright").attr("terracing", 0).attr("skip", 5).attr("relax", 0).attr("curve", 0);
|
||||
svg.select("#oceanic > *").attr("id", "oceanicPattern");
|
||||
oceanLayers.attr("layers", "-6,-3,-1");
|
||||
gridOverlay.attr("type", "pointyHex").attr("size", 10);
|
||||
|
||||
// v1.11 added cultures heirarchy tree
|
||||
if (pack.cultures[1] && !pack.cultures[1].code) {
|
||||
pack.cultures
|
||||
.filter(c => c.i)
|
||||
.forEach(c => {
|
||||
c.origin = 0;
|
||||
c.code = c.name.slice(0, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// v1.11 had an issue with fogging being displayed on load
|
||||
unfog();
|
||||
|
||||
// v1.2 added new terrain attributes
|
||||
if (!terrain.attr("set")) terrain.attr("set", "simple");
|
||||
if (!terrain.attr("size")) terrain.attr("size", 1);
|
||||
if (!terrain.attr("density")) terrain.attr("density", 0.4);
|
||||
}
|
||||
|
||||
if (version < 1.21) {
|
||||
// v1.11 replaced "display" attribute by "display" style
|
||||
viewbox.selectAll("g").each(function () {
|
||||
if (this.hasAttribute("display")) {
|
||||
this.removeAttribute("display");
|
||||
this.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// v1.21 added rivers data to pack
|
||||
pack.rivers = []; // rivers data
|
||||
rivers.selectAll("path").each(function () {
|
||||
const i = +this.id.slice(5);
|
||||
const length = this.getTotalLength() / 2;
|
||||
const s = this.getPointAtLength(length),
|
||||
e = this.getPointAtLength(0);
|
||||
const source = findCell(s.x, s.y),
|
||||
mouth = findCell(e.x, e.y);
|
||||
const name = Rivers.getName(mouth);
|
||||
const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River";
|
||||
pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type});
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 1.22) {
|
||||
// v1.22 changed state neighbors from Set object to array
|
||||
BurgsAndStates.collectStatistics();
|
||||
}
|
||||
|
||||
if (version < 1.3) {
|
||||
// v1.3 added global options object
|
||||
const winds = options.slice(); // previostly wind was saved in settings[19]
|
||||
const year = rand(100, 2000);
|
||||
const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era";
|
||||
const eraShort = era[0] + "E";
|
||||
const military = Military.getDefaultOptions();
|
||||
options = {winds, year, era, eraShort, military};
|
||||
|
||||
// v1.3 added campaings data for all states
|
||||
BurgsAndStates.generateCampaigns();
|
||||
|
||||
// v1.3 added militry layer
|
||||
armies = viewbox.insert("g", "#icons").attr("id", "armies");
|
||||
armies
|
||||
.attr("opacity", 1)
|
||||
.attr("fill-opacity", 1)
|
||||
.attr("font-size", 6)
|
||||
.attr("box-size", 3)
|
||||
.attr("stroke", "#000")
|
||||
.attr("stroke-width", 0.3);
|
||||
turnButtonOn("toggleMilitary");
|
||||
Military.generate();
|
||||
}
|
||||
|
||||
if (version < 1.4) {
|
||||
// v1.35 added dry lakes
|
||||
if (!lakes.select("#dry").size()) {
|
||||
lakes.append("g").attr("id", "dry");
|
||||
lakes
|
||||
.select("#dry")
|
||||
.attr("opacity", 1)
|
||||
.attr("fill", "#c9bfa7")
|
||||
.attr("stroke", "#8e816f")
|
||||
.attr("stroke-width", 0.7)
|
||||
.attr("filter", null);
|
||||
}
|
||||
|
||||
// v1.4 added ice layer
|
||||
ice = viewbox.insert("g", "#coastline").attr("id", "ice").style("display", "none");
|
||||
ice
|
||||
.attr("opacity", null)
|
||||
.attr("fill", "#e8f0f6")
|
||||
.attr("stroke", "#e8f0f6")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("filter", "url(#dropShadow05)");
|
||||
drawIce();
|
||||
|
||||
// v1.4 added icon and power attributes for units
|
||||
for (const unit of options.military) {
|
||||
if (!unit.icon) unit.icon = getUnitIcon(unit.type);
|
||||
if (!unit.power) unit.power = unit.crew;
|
||||
}
|
||||
|
||||
function getUnitIcon(type) {
|
||||
if (type === "naval") return "🌊";
|
||||
if (type === "ranged") return "🏹";
|
||||
if (type === "mounted") return "🐴";
|
||||
if (type === "machinery") return "💣";
|
||||
if (type === "armored") return "🐢";
|
||||
if (type === "aviation") return "🦅";
|
||||
if (type === "magical") return "🔮";
|
||||
else return "⚔️";
|
||||
}
|
||||
|
||||
// v1.4 added state reference for regiments
|
||||
pack.states.filter(s => s.military).forEach(s => s.military.forEach(r => (r.state = s.i)));
|
||||
}
|
||||
|
||||
if (version < 1.5) {
|
||||
// not need to store default styles from v 1.5
|
||||
localStorage.removeItem("styleClean");
|
||||
localStorage.removeItem("styleGloom");
|
||||
localStorage.removeItem("styleAncient");
|
||||
localStorage.removeItem("styleMonochrome");
|
||||
|
||||
// v1.5 cultures has shield attribute
|
||||
pack.cultures.forEach(culture => {
|
||||
if (culture.removed) return;
|
||||
culture.shield = Cultures.getRandomShield();
|
||||
});
|
||||
|
||||
// v1.5 added burg type value
|
||||
pack.burgs.forEach(burg => {
|
||||
if (!burg.i || burg.removed) return;
|
||||
burg.type = BurgsAndStates.getType(burg.cell, burg.port);
|
||||
});
|
||||
|
||||
// v1.5 added emblems
|
||||
defs.append("g").attr("id", "defs-emblems");
|
||||
emblems = viewbox.insert("g", "#population").attr("id", "emblems").style("display", "none");
|
||||
emblems.append("g").attr("id", "burgEmblems");
|
||||
emblems.append("g").attr("id", "provinceEmblems");
|
||||
emblems.append("g").attr("id", "stateEmblems");
|
||||
regenerateEmblems();
|
||||
toggleEmblems();
|
||||
|
||||
// v1.5 changed releif icons data
|
||||
terrain.selectAll("use").each(function () {
|
||||
const type = this.getAttribute("data-type") || this.getAttribute("xlink:href");
|
||||
this.removeAttribute("xlink:href");
|
||||
this.removeAttribute("data-type");
|
||||
this.removeAttribute("data-size");
|
||||
this.setAttribute("href", type);
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 1.6) {
|
||||
// v1.6 changed rivers data
|
||||
for (const river of pack.rivers) {
|
||||
const el = document.getElementById("river" + river.i);
|
||||
if (el) {
|
||||
river.widthFactor = +el.getAttribute("data-width");
|
||||
el.removeAttribute("data-width");
|
||||
el.removeAttribute("data-increment");
|
||||
river.discharge = pack.cells.fl[river.mouth] || 1;
|
||||
river.width = rn(river.length / 100, 2);
|
||||
river.sourceWidth = 0.1;
|
||||
} else {
|
||||
Rivers.remove(river.i);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.6 changed lakes data
|
||||
for (const f of pack.features) {
|
||||
if (f.type !== "lake") continue;
|
||||
if (f.evaporation) continue;
|
||||
|
||||
f.flux = f.flux || f.cells * 3;
|
||||
f.temp = grid.cells.temp[pack.cells.g[f.firstCell]];
|
||||
f.height = f.height || d3.min(pack.cells.c[f.firstCell].map(c => pack.cells.h[c]).filter(h => h >= 20));
|
||||
const height = (f.height - 18) ** heightExponentInput.value;
|
||||
const evaporation = ((700 * (f.temp + 0.006 * height)) / 50 + 75) / (80 - f.temp);
|
||||
f.evaporation = rn(evaporation * f.cells);
|
||||
f.name = f.name || Lakes.getName(f);
|
||||
delete f.river;
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 1.61) {
|
||||
// v1.61 changed rulers data
|
||||
ruler.style("display", null);
|
||||
rulers = new Rulers();
|
||||
|
||||
ruler.selectAll(".ruler > .white").each(function () {
|
||||
const x1 = +this.getAttribute("x1");
|
||||
const y1 = +this.getAttribute("y1");
|
||||
const x2 = +this.getAttribute("x2");
|
||||
const y2 = +this.getAttribute("y2");
|
||||
if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return;
|
||||
const points = [
|
||||
[x1, y1],
|
||||
[x2, y2]
|
||||
];
|
||||
rulers.create(Ruler, points);
|
||||
});
|
||||
|
||||
ruler.selectAll("g.opisometer").each(function () {
|
||||
const pointsString = this.dataset.points;
|
||||
if (!pointsString) return;
|
||||
const points = JSON.parse(pointsString);
|
||||
rulers.create(Opisometer, points);
|
||||
});
|
||||
|
||||
ruler.selectAll("path.planimeter").each(function () {
|
||||
const length = this.getTotalLength();
|
||||
if (length < 30) return;
|
||||
|
||||
const step = length > 1000 ? 40 : length > 400 ? 20 : 10;
|
||||
const increment = length / Math.ceil(length / step);
|
||||
const points = [];
|
||||
for (let i = 0; i <= length; i += increment) {
|
||||
const point = this.getPointAtLength(i);
|
||||
points.push([point.x | 0, point.y | 0]);
|
||||
}
|
||||
|
||||
rulers.create(Planimeter, points);
|
||||
});
|
||||
|
||||
ruler.selectAll("*").remove();
|
||||
|
||||
if (rulers.data.length) {
|
||||
turnButtonOn("toggleRulers");
|
||||
rulers.draw();
|
||||
} else turnButtonOff("toggleRulers");
|
||||
|
||||
// 1.61 changed oceanicPattern from rect to image
|
||||
const pattern = document.getElementById("oceanic");
|
||||
const filter = pattern.firstElementChild.getAttribute("filter");
|
||||
const href = filter ? "./images/" + filter.replace("url(#", "").replace(")", "") + ".png" : "";
|
||||
pattern.innerHTML = /* html */ `<image id="oceanicPattern" href=${href} width="100" height="100" opacity="0.2"></image>`;
|
||||
}
|
||||
|
||||
if (version < 1.62) {
|
||||
// v1.62 changed grid data
|
||||
gridOverlay.attr("size", null);
|
||||
}
|
||||
|
||||
if (version < 1.63) {
|
||||
// v1.63 changed ocean pattern opacity element
|
||||
const oceanPattern = document.getElementById("oceanPattern");
|
||||
if (oceanPattern) oceanPattern.removeAttribute("opacity");
|
||||
const oceanicPattern = document.getElementById("oceanicPattern");
|
||||
if (!oceanicPattern.getAttribute("opacity")) oceanicPattern.setAttribute("opacity", 0.2);
|
||||
|
||||
// v 1.63 moved label text-shadow from css to editable inline style
|
||||
burgLabels.select("#cities").style("text-shadow", "white 0 0 4px");
|
||||
burgLabels.select("#towns").style("text-shadow", "white 0 0 4px");
|
||||
labels.select("#states").style("text-shadow", "white 0 0 4px");
|
||||
labels.select("#addedLabels").style("text-shadow", "white 0 0 4px");
|
||||
}
|
||||
|
||||
if (version < 1.64) {
|
||||
// v1.64 change states style
|
||||
const opacity = regions.attr("opacity");
|
||||
const filter = regions.attr("filter");
|
||||
statesBody.attr("opacity", opacity).attr("filter", filter);
|
||||
statesHalo.attr("opacity", opacity).attr("filter", "blur(5px)");
|
||||
regions.attr("opacity", null).attr("filter", null);
|
||||
}
|
||||
|
||||
if (version < 1.65) {
|
||||
// v1.65 changed rivers data
|
||||
d3.select("#rivers").attr("style", null); // remove style to unhide layer
|
||||
const {cells, rivers} = pack;
|
||||
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
|
||||
|
||||
for (const river of rivers) {
|
||||
const node = document.getElementById("river" + river.i);
|
||||
if (node && !river.cells) {
|
||||
const riverCells = [];
|
||||
const riverPoints = [];
|
||||
|
||||
const length = node.getTotalLength() / 2;
|
||||
if (!length) continue;
|
||||
const segments = Math.ceil(length / 6);
|
||||
const increment = length / segments;
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const shift = increment * i;
|
||||
const {x: x1, y: y1} = node.getPointAtLength(length + shift);
|
||||
const {x: x2, y: y2} = node.getPointAtLength(length - shift);
|
||||
const x = rn((x1 + x2) / 2, 1);
|
||||
const y = rn((y1 + y2) / 2, 1);
|
||||
|
||||
const cell = findCell(x, y);
|
||||
riverPoints.push([x, y]);
|
||||
riverCells.push(cell);
|
||||
}
|
||||
|
||||
river.cells = riverCells;
|
||||
river.points = riverPoints;
|
||||
}
|
||||
|
||||
river.widthFactor = defaultWidthFactor;
|
||||
|
||||
cells.i.forEach(i => {
|
||||
const riverInWater = cells.r[i] && cells.h[i] < 20;
|
||||
if (riverInWater) cells.r[i] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 1.652) {
|
||||
// remove style to unhide layers
|
||||
rivers.attr("style", null);
|
||||
borders.attr("style", null);
|
||||
}
|
||||
|
||||
if (version < 1.7) {
|
||||
// v1.7 changed markers data
|
||||
const defs = document.getElementById("defs-markers");
|
||||
const markersGroup = document.getElementById("markers");
|
||||
|
||||
if (defs && markersGroup) {
|
||||
const markerElements = markersGroup.querySelectorAll("use");
|
||||
const rescale = +markersGroup.getAttribute("rescale");
|
||||
|
||||
pack.markers = Array.from(markerElements).map((el, i) => {
|
||||
const id = el.getAttribute("id");
|
||||
const note = notes.find(note => note.id === id);
|
||||
if (note) note.id = `marker${i}`;
|
||||
|
||||
let x = +el.dataset.x;
|
||||
let y = +el.dataset.y;
|
||||
|
||||
const transform = el.getAttribute("transform");
|
||||
if (transform) {
|
||||
const [dx, dy] = parseTransform(transform);
|
||||
if (dx) x += +dx;
|
||||
if (dy) y += +dy;
|
||||
}
|
||||
const cell = findCell(x, y);
|
||||
const size = rn(rescale ? el.dataset.size * 30 : el.getAttribute("width"), 1);
|
||||
|
||||
const href = el.href.baseVal;
|
||||
const type = href.replace("#marker_", "");
|
||||
const symbol = defs?.querySelector(`symbol${href}`);
|
||||
const text = symbol?.querySelector("text");
|
||||
const circle = symbol?.querySelector("circle");
|
||||
|
||||
const icon = text?.innerHTML;
|
||||
const px = text && Number(text.getAttribute("font-size")?.replace("px", ""));
|
||||
const dx = text && Number(text.getAttribute("x")?.replace("%", ""));
|
||||
const dy = text && Number(text.getAttribute("y")?.replace("%", ""));
|
||||
const fill = circle && circle.getAttribute("fill");
|
||||
const stroke = circle && circle.getAttribute("stroke");
|
||||
|
||||
const marker = {i, icon, type, x, y, size, cell};
|
||||
if (size && size !== 30) marker.size = size;
|
||||
if (!isNaN(px) && px !== 12) marker.px = px;
|
||||
if (!isNaN(dx) && dx !== 50) marker.dx = dx;
|
||||
if (!isNaN(dy) && dy !== 50) marker.dy = dy;
|
||||
if (fill && fill !== "#ffffff") marker.fill = fill;
|
||||
if (stroke && stroke !== "#000000") marker.stroke = stroke;
|
||||
if (circle?.getAttribute("opacity") === "0") marker.pin = "no";
|
||||
|
||||
return marker;
|
||||
});
|
||||
|
||||
markersGroup.style.display = null;
|
||||
defs?.remove();
|
||||
markerElements.forEach(el => el.remove());
|
||||
if (layerIsOn("markers")) drawMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 1.72) {
|
||||
// v1.72 renamed custom style presets
|
||||
const storedStyles = Object.keys(localStorage).filter(key => key.startsWith("style"));
|
||||
storedStyles.forEach(styleName => {
|
||||
const style = localStorage.getItem(styleName);
|
||||
const newStyleName = styleName.replace(/^style/, customPresetPrefix);
|
||||
localStorage.setItem(newStyleName, style);
|
||||
localStorage.removeItem(styleName);
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 1.73) {
|
||||
// v1.73 moved the hatching patterns out of the user's SVG
|
||||
document.getElementById("hatching")?.remove();
|
||||
|
||||
// v1.73 added zone type to UI, ensure type is populated
|
||||
const zones = Array.from(document.querySelectorAll("#zones > g"));
|
||||
zones.forEach(zone => {
|
||||
if (!zone.dataset.type) zone.dataset.type = "Unknown";
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 1.84) {
|
||||
// v1.84.0 added grid.cellsDesired to stored data
|
||||
if (!grid.cellsDesired) grid.cellsDesired = rn((graphWidth * graphHeight) / grid.spacing ** 2, -3);
|
||||
}
|
||||
|
||||
if (version < 1.85) {
|
||||
// v1.84.0 moved intial screen out of maon svg
|
||||
svg.select("#initial").remove();
|
||||
}
|
||||
|
||||
if (version < 1.86) {
|
||||
// v1.86.0 added multi-origin culture and religion hierarchy trees
|
||||
for (const culture of pack.cultures) {
|
||||
culture.origins = [culture.origin];
|
||||
delete culture.origin;
|
||||
}
|
||||
|
||||
for (const religion of pack.religions) {
|
||||
religion.origins = [religion.origin];
|
||||
delete religion.origin;
|
||||
}
|
||||
}
|
||||
}
|
||||
919
src/modules/dynamic/editors/cultures-editor.js
Normal file
919
src/modules/dynamic/editors/cultures-editor.js
Normal file
|
|
@ -0,0 +1,919 @@
|
|||
import {restoreDefaultEvents} from "/src/scripts/events";
|
||||
import {findAll, findCell, getPackPolygon, isLand} from "/src/utils/graphUtils";
|
||||
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
|
||||
import {byId} from "/src/utils/shorthands";
|
||||
import {rn} from "/src/utils/numberUtils";
|
||||
import {capitalize} from "/src/utils/stringUtils";
|
||||
import {si} from "/src/utils/unitUtils";
|
||||
import {abbreviate} from "/src/utils/languageUtils";
|
||||
import {debounce} from "/src/utils/functionUtils";
|
||||
|
||||
const $body = insertEditorHtml();
|
||||
addListeners();
|
||||
|
||||
const cultureTypes = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
|
||||
|
||||
export function open() {
|
||||
closeDialogs("#culturesEditor, .stable");
|
||||
if (!layerIsOn("toggleCultures")) toggleCultures();
|
||||
if (layerIsOn("toggleStates")) toggleStates();
|
||||
if (layerIsOn("toggleBiomes")) toggleBiomes();
|
||||
if (layerIsOn("toggleReligions")) toggleReligions();
|
||||
if (layerIsOn("toggleProvinces")) toggleProvinces();
|
||||
|
||||
refreshCulturesEditor();
|
||||
|
||||
$("#culturesEditor").dialog({
|
||||
title: "Cultures Editor",
|
||||
resizable: false,
|
||||
close: closeCulturesEditor,
|
||||
position: {my: "right top", at: "right-10 top+10", of: "svg"}
|
||||
});
|
||||
$body.focus();
|
||||
}
|
||||
|
||||
function insertEditorHtml() {
|
||||
const editorHtml = /* html */ `<div id="culturesEditor" class="dialog stable">
|
||||
<div id="culturesHeader" class="header" style="grid-template-columns: 10em 7em 8em 4em 8em 5em 8em 8em">
|
||||
<div data-tip="Click to sort by culture name" class="sortable alphabetically" data-sortby="name">Culture </div>
|
||||
<div data-tip="Click to sort by type" class="sortable alphabetically" data-sortby="type">Type </div>
|
||||
<div data-tip="Click to sort by culture namesbase" class="sortable" data-sortby="base">Namesbase </div>
|
||||
<div data-tip="Click to sort by culture cells count" class="sortable hide" data-sortby="cells">Cells </div>
|
||||
<div data-tip="Click to sort by expansionism" class="sortable hide" data-sortby="expansionism">Expansion </div>
|
||||
<div data-tip="Click to sort by culture area" class="sortable hide" data-sortby="area">Area </div>
|
||||
<div data-tip="Click to sort by culture population" class="sortable hide icon-sort-number-down" data-sortby="population">Population </div>
|
||||
<div data-tip="Click to sort by culture emblems shape" class="sortable alphabetically hide" data-sortby="emblems">Emblems </div>
|
||||
</div>
|
||||
<div id="culturesBody" class="table" data-type="absolute"></div>
|
||||
|
||||
<div id="culturesFooter" class="totalLine">
|
||||
<div data-tip="Cultures number" style="margin-left: 12px">Cultures: <span id="culturesFooterCultures">0</span></div>
|
||||
<div data-tip="Total land cells number" style="margin-left: 12px">Cells: <span id="culturesFooterCells">0</span></div>
|
||||
<div data-tip="Total land area" style="margin-left: 12px">Land Area: <span id="culturesFooterArea">0</span></div>
|
||||
<div data-tip="Total population" style="margin-left: 12px">Population: <span id="culturesFooterPopulation">0</span></div>
|
||||
</div>
|
||||
|
||||
<div id="culturesBottom">
|
||||
<button id="culturesEditorRefresh" data-tip="Refresh the Editor" class="icon-cw"></button>
|
||||
<button id="culturesEditStyle" data-tip="Edit cultures style in Style Editor" class="icon-adjust"></button>
|
||||
<button id="culturesLegend" data-tip="Toggle Legend box" class="icon-list-bullet"></button>
|
||||
<button id="culturesPercentage" data-tip="Toggle percentage / absolute values display mode" class="icon-percent"></button>
|
||||
<button id="culturesHeirarchy" data-tip="Show cultures hierarchy tree" class="icon-sitemap"></button>
|
||||
<button id="culturesManually" data-tip="Manually re-assign cultures" class="icon-brush"></button>
|
||||
<div id="culturesManuallyButtons" style="display: none">
|
||||
<label data-tip="Change brush size" data-shortcut="+ (increase), – (decrease)" class="italic">Brush size:
|
||||
<input
|
||||
id="culturesManuallyBrush"
|
||||
type="range"
|
||||
min="5"
|
||||
max="99"
|
||||
value="15"
|
||||
style="width: 7em"
|
||||
/>
|
||||
<input
|
||||
id="culturesManuallyBrushNumber"
|
||||
type="number"
|
||||
min="5"
|
||||
max="99"
|
||||
value="15"
|
||||
/> </label><br />
|
||||
<button id="culturesManuallyApply" data-tip="Apply assignment" class="icon-check"></button>
|
||||
<button id="culturesManuallyCancel" data-tip="Cancel assignment" class="icon-cancel"></button>
|
||||
</div>
|
||||
<button id="culturesEditNamesBase" data-tip="Edit a database used for names generation" class="icon-font"></button>
|
||||
<button id="culturesAdd" data-tip="Add a new culture. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button id="culturesExport" data-tip="Download cultures-related data" class="icon-download"></button>
|
||||
<button id="culturesImport" data-tip="Upload cultures-related data" class="icon-upload"></button>
|
||||
<button id="culturesRecalculate" data-tip="Recalculate cultures based on current values of growth-related attributes" class="icon-retweet"></button>
|
||||
<span data-tip="Allow culture centers, expansion and type changes to take an immediate effect">
|
||||
<input id="culturesAutoChange" class="checkbox" type="checkbox" />
|
||||
<label for="culturesAutoChange" class="checkbox-label"><i>auto-apply changes</i></label>
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
byId("dialogs").insertAdjacentHTML("beforeend", editorHtml);
|
||||
return byId("culturesBody");
|
||||
}
|
||||
|
||||
function addListeners() {
|
||||
applySortingByHeader("culturesHeader");
|
||||
|
||||
byId("culturesEditorRefresh").on("click", refreshCulturesEditor);
|
||||
byId("culturesEditStyle").on("click", () => editStyle("cults"));
|
||||
byId("culturesLegend").on("click", toggleLegend);
|
||||
byId("culturesPercentage").on("click", togglePercentageMode);
|
||||
byId("culturesHeirarchy").on("click", showHierarchy);
|
||||
byId("culturesRecalculate").on("click", () => recalculateCultures(true));
|
||||
byId("culturesManually").on("click", enterCultureManualAssignent);
|
||||
byId("culturesManuallyApply").on("click", applyCultureManualAssignent);
|
||||
byId("culturesManuallyCancel").on("click", () => exitCulturesManualAssignment());
|
||||
byId("culturesEditNamesBase").on("click", editNamesbase);
|
||||
byId("culturesAdd").on("click", enterAddCulturesMode);
|
||||
byId("culturesExport").on("click", downloadCulturesCsv);
|
||||
byId("culturesImport").on("click", () => byId("culturesCSVToLoad").click());
|
||||
byId("culturesCSVToLoad").on("change", uploadCulturesData);
|
||||
}
|
||||
|
||||
function refreshCulturesEditor() {
|
||||
culturesCollectStatistics();
|
||||
culturesEditorAddLines();
|
||||
drawCultureCenters();
|
||||
}
|
||||
|
||||
function culturesCollectStatistics() {
|
||||
const {cells, cultures, burgs} = pack;
|
||||
cultures.forEach(c => {
|
||||
c.cells = c.area = c.rural = c.urban = 0;
|
||||
});
|
||||
|
||||
for (const i of cells.i) {
|
||||
if (cells.h[i] < 20) continue;
|
||||
const cultureId = cells.culture[i];
|
||||
cultures[cultureId].cells += 1;
|
||||
cultures[cultureId].area += cells.area[i];
|
||||
cultures[cultureId].rural += cells.pop[i];
|
||||
const burgId = cells.burg[i];
|
||||
if (burgId) cultures[cultureId].urban += burgs[burgId].population;
|
||||
}
|
||||
}
|
||||
|
||||
function culturesEditorAddLines() {
|
||||
const unit = getAreaUnit();
|
||||
let lines = "";
|
||||
let totalArea = 0;
|
||||
let totalPopulation = 0;
|
||||
|
||||
const emblemShapeGroup = byId("emblemShape")?.selectedOptions[0]?.parentNode?.label;
|
||||
const selectShape = emblemShapeGroup === "Diversiform";
|
||||
|
||||
for (const c of pack.cultures) {
|
||||
if (c.removed) continue;
|
||||
const area = getArea(c.area);
|
||||
const rural = c.rural * populationRate;
|
||||
const urban = c.urban * populationRate * urbanization;
|
||||
const population = rn(rural + urban);
|
||||
const populationTip = `Total population: ${si(population)}. Rural population: ${si(rural)}. Urban population: ${si(
|
||||
urban
|
||||
)}. Click to edit`;
|
||||
totalArea += area;
|
||||
totalPopulation += population;
|
||||
|
||||
if (!c.i) {
|
||||
// Uncultured (neutral) line
|
||||
lines += /* html */ `<div
|
||||
class="states"
|
||||
data-id="${c.i}"
|
||||
data-name="${c.name}"
|
||||
data-color=""
|
||||
data-cells="${c.cells}"
|
||||
data-area="${area}"
|
||||
data-population="${population}"
|
||||
data-base="${c.base}"
|
||||
data-type=""
|
||||
data-expansionism=""
|
||||
data-emblems="${c.shield}"
|
||||
>
|
||||
<svg width="11" height="11" class="placeholder"></svg>
|
||||
<input data-tip="Neutral culture name. Click and type to change" class="cultureName italic" style="width: 7em"
|
||||
value="${c.name}" autocorrect="off" spellcheck="false" />
|
||||
<span class="icon-cw placeholder"></span>
|
||||
<select class="cultureType placeholder">${getTypeOptions(c.type)}</select>
|
||||
<select data-tip="Culture namesbase. Click to change. Click on arrows to re-generate names"
|
||||
class="cultureBase">${getBaseOptions(c.base)}</select>
|
||||
<span data-tip="Cells count" class="icon-check-empty hide"></span>
|
||||
<div data-tip="Cells count" class="cultureCells hide" style="width: 4em">${c.cells}</div>
|
||||
<span class="icon-resize-full placeholder hide"></span>
|
||||
<input class="cultureExpan placeholder hide" type="number" />
|
||||
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o hide"></span>
|
||||
<div data-tip="Culture area" class="cultureArea hide" style="width: 6em">${si(area)} ${unit}</div>
|
||||
<span data-tip="${populationTip}" class="icon-male hide"></span>
|
||||
<div data-tip="${populationTip}" class="culturePopulation hide pointer"
|
||||
style="width: 5em">${si(population)}</div>
|
||||
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
|
||||
${getShapeOptions(selectShape, c.shield)}
|
||||
</div>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines += /* html */ `<div
|
||||
class="states"
|
||||
data-id="${c.i}"
|
||||
data-name="${c.name}"
|
||||
data-color="${c.color}"
|
||||
data-cells="${c.cells}"
|
||||
data-area="${area}"
|
||||
data-population="${population}"
|
||||
data-base="${c.base}"
|
||||
data-type="${c.type}"
|
||||
data-expansionism="${c.expansionism}"
|
||||
data-emblems="${c.shield}"
|
||||
>
|
||||
<fill-box fill="${c.color}"></fill-box>
|
||||
<input data-tip="Culture name. Click and type to change" class="cultureName" style="width: 7em"
|
||||
value="${c.name}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Regenerate culture name" class="icon-cw hiddenIcon" style="visibility: hidden"></span>
|
||||
<select data-tip="Culture type. Defines growth model. Click to change"
|
||||
class="cultureType">${getTypeOptions(c.type)}</select>
|
||||
<select data-tip="Culture namesbase. Click to change. Click on arrows to re-generate names"
|
||||
class="cultureBase">${getBaseOptions(c.base)}</select>
|
||||
<span data-tip="Cells count" class="icon-check-empty hide"></span>
|
||||
<div data-tip="Cells count" class="cultureCells hide" style="width: 4em">${c.cells}</div>
|
||||
<span data-tip="Culture expansionism. Defines competitive size" class="icon-resize-full hide"></span>
|
||||
<input
|
||||
data-tip="Culture expansionism. Defines competitive size. Click to change, then click Recalculate to apply change"
|
||||
class="cultureExpan hide"
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
step=".1"
|
||||
value=${c.expansionism}
|
||||
/>
|
||||
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o hide"></span>
|
||||
<div data-tip="Culture area" class="cultureArea hide" style="width: 6em">${si(area)} ${unit}</div>
|
||||
<span data-tip="${populationTip}" class="icon-male hide"></span>
|
||||
<div data-tip="${populationTip}" class="culturePopulation hide pointer"
|
||||
style="width: 5em">${si(population)}</div>
|
||||
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
|
||||
${getShapeOptions(selectShape, c.shield)}
|
||||
<span data-tip="Remove culture" class="icon-trash-empty hide"></span>
|
||||
</div>`;
|
||||
}
|
||||
$body.innerHTML = lines;
|
||||
|
||||
// update footer
|
||||
byId("culturesFooterCultures").innerHTML = pack.cultures.filter(c => c.i && !c.removed).length;
|
||||
byId("culturesFooterCells").innerHTML = pack.cells.h.filter(h => h >= 20).length;
|
||||
byId("culturesFooterArea").innerHTML = `${si(totalArea)} ${unit}`;
|
||||
byId("culturesFooterPopulation").innerHTML = si(totalPopulation);
|
||||
byId("culturesFooterArea").dataset.area = totalArea;
|
||||
byId("culturesFooterPopulation").dataset.population = totalPopulation;
|
||||
|
||||
// add listeners
|
||||
$body.querySelectorAll(":scope > div").forEach($line => {
|
||||
$line.on("mouseenter", cultureHighlightOn);
|
||||
$line.on("mouseleave", cultureHighlightOff);
|
||||
$line.on("click", selectCultureOnLineClick);
|
||||
});
|
||||
$body.querySelectorAll("fill-box").forEach($el => $el.on("click", cultureChangeColor));
|
||||
$body.querySelectorAll("div > input.cultureName").forEach($el => $el.on("input", cultureChangeName));
|
||||
$body.querySelectorAll("div > span.icon-cw").forEach($el => $el.on("click", cultureRegenerateName));
|
||||
$body.querySelectorAll("div > input.cultureExpan").forEach($el => $el.on("input", cultureChangeExpansionism));
|
||||
$body.querySelectorAll("div > select.cultureType").forEach($el => $el.on("change", cultureChangeType));
|
||||
$body.querySelectorAll("div > select.cultureBase").forEach($el => $el.on("change", cultureChangeBase));
|
||||
$body.querySelectorAll("div > select.cultureEmblems").forEach($el => $el.on("change", cultureChangeEmblemsShape));
|
||||
$body.querySelectorAll("div > div.culturePopulation").forEach($el => $el.on("click", changePopulation));
|
||||
$body.querySelectorAll("div > span.icon-arrows-cw").forEach($el => $el.on("click", cultureRegenerateBurgs));
|
||||
$body.querySelectorAll("div > span.icon-trash-empty").forEach($el => $el.on("click", cultureRemovePrompt));
|
||||
|
||||
const $culturesHeader = byId("culturesHeader");
|
||||
$culturesHeader.querySelector("div[data-sortby='emblems']").style.display = selectShape ? "inline-block" : "none";
|
||||
|
||||
if ($body.dataset.type === "percentage") {
|
||||
$body.dataset.type = "absolute";
|
||||
togglePercentageMode();
|
||||
}
|
||||
applySorting($culturesHeader);
|
||||
$("#culturesEditor").dialog({width: "fit-content"});
|
||||
}
|
||||
|
||||
function getTypeOptions(type) {
|
||||
let options = "";
|
||||
cultureTypes.forEach(t => (options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`));
|
||||
return options;
|
||||
}
|
||||
|
||||
function getBaseOptions(base) {
|
||||
let options = "";
|
||||
nameBases.forEach((n, i) => (options += `<option ${base === i ? "selected" : ""} value="${i}">${n.name}</option>`));
|
||||
return options;
|
||||
}
|
||||
|
||||
function getShapeOptions(selectShape, selected) {
|
||||
if (!selectShape) return "";
|
||||
|
||||
const shapes = Object.keys(COA.shields.types)
|
||||
.map(type => Object.keys(COA.shields[type]))
|
||||
.flat();
|
||||
const options = shapes.map(
|
||||
shape => `<option ${shape === selected ? "selected" : ""} value="${shape}">${capitalize(shape)}</option>`
|
||||
);
|
||||
return `<select data-tip="Emblem shape associated with culture. Click to change" class="cultureEmblems hide">${options}</select>`;
|
||||
}
|
||||
|
||||
const cultureHighlightOn = debounce(event => {
|
||||
const cultureId = Number(event.id || event.target.dataset.id);
|
||||
|
||||
if (!layerIsOn("toggleCultures")) return;
|
||||
if (customization) return;
|
||||
|
||||
const animate = d3.transition().duration(2000).ease(d3.easeSinIn);
|
||||
cults
|
||||
.select("#culture" + cultureId)
|
||||
.raise()
|
||||
.transition(animate)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("stroke", "#d0240f");
|
||||
debug
|
||||
.select("#cultureCenter" + cultureId)
|
||||
.raise()
|
||||
.transition(animate)
|
||||
.attr("r", 8)
|
||||
.attr("stroke", "#d0240f");
|
||||
}, 200);
|
||||
|
||||
function cultureHighlightOff(event) {
|
||||
const cultureId = Number(event.id || event.target.dataset.id);
|
||||
|
||||
if (!layerIsOn("toggleCultures")) return;
|
||||
cults
|
||||
.select("#culture" + cultureId)
|
||||
.transition()
|
||||
.attr("stroke-width", null)
|
||||
.attr("stroke", null);
|
||||
debug
|
||||
.select("#cultureCenter" + cultureId)
|
||||
.transition()
|
||||
.attr("r", 6)
|
||||
.attr("stroke", null);
|
||||
}
|
||||
|
||||
function cultureChangeColor() {
|
||||
const $el = this;
|
||||
const currentFill = $el.getAttribute("fill");
|
||||
const cultureId = +$el.parentNode.dataset.id;
|
||||
|
||||
const callback = newFill => {
|
||||
$el.fill = newFill;
|
||||
pack.cultures[cultureId].color = newFill;
|
||||
cults
|
||||
.select("#culture" + cultureId)
|
||||
.attr("fill", newFill)
|
||||
.attr("stroke", newFill);
|
||||
debug.select("#cultureCenter" + cultureId).attr("fill", newFill);
|
||||
};
|
||||
|
||||
openPicker(currentFill, callback);
|
||||
}
|
||||
|
||||
function cultureChangeName() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.name = this.value;
|
||||
pack.cultures[culture].name = this.value;
|
||||
pack.cultures[culture].code = abbreviate(
|
||||
this.value,
|
||||
pack.cultures.map(c => c.code)
|
||||
);
|
||||
}
|
||||
|
||||
function cultureRegenerateName() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
const name = Names.getCultureShort(culture);
|
||||
this.parentNode.querySelector("input.cultureName").value = name;
|
||||
pack.cultures[culture].name = name;
|
||||
}
|
||||
|
||||
function cultureChangeExpansionism() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.expansionism = this.value;
|
||||
pack.cultures[culture].expansionism = +this.value;
|
||||
recalculateCultures();
|
||||
}
|
||||
|
||||
function cultureChangeType() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.type = this.value;
|
||||
pack.cultures[culture].type = this.value;
|
||||
recalculateCultures();
|
||||
}
|
||||
|
||||
function cultureChangeBase() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
const v = +this.value;
|
||||
this.parentNode.dataset.base = pack.cultures[culture].base = v;
|
||||
}
|
||||
|
||||
function cultureChangeEmblemsShape() {
|
||||
const culture = +this.parentNode.dataset.id;
|
||||
const shape = this.value;
|
||||
this.parentNode.dataset.emblems = pack.cultures[culture].shield = shape;
|
||||
|
||||
const rerenderCOA = (id, coa) => {
|
||||
const $coa = byId(id);
|
||||
if (!$coa) return; // not rendered
|
||||
$coa.remove();
|
||||
COArenderer.trigger(id, coa);
|
||||
};
|
||||
|
||||
pack.states.forEach(state => {
|
||||
if (state.culture !== culture || !state.i || state.removed || !state.coa || state.coa === "custom") return;
|
||||
if (shape === state.coa.shield) return;
|
||||
state.coa.shield = shape;
|
||||
rerenderCOA("stateCOA" + state.i, state.coa);
|
||||
});
|
||||
|
||||
pack.provinces.forEach(province => {
|
||||
if (
|
||||
pack.cells.culture[province.center] !== culture ||
|
||||
!province.i ||
|
||||
province.removed ||
|
||||
!province.coa ||
|
||||
province.coa === "custom"
|
||||
)
|
||||
return;
|
||||
if (shape === province.coa.shield) return;
|
||||
province.coa.shield = shape;
|
||||
rerenderCOA("provinceCOA" + province.i, province.coa);
|
||||
});
|
||||
|
||||
pack.burgs.forEach(burg => {
|
||||
if (burg.culture !== culture || !burg.i || burg.removed || !burg.coa || burg.coa === "custom") return;
|
||||
if (shape === burg.coa.shield) return;
|
||||
burg.coa.shield = shape;
|
||||
rerenderCOA("burgCOA" + burg.i, burg.coa);
|
||||
});
|
||||
}
|
||||
|
||||
function changePopulation() {
|
||||
const cultureId = +this.parentNode.dataset.id;
|
||||
const culture = pack.cultures[cultureId];
|
||||
if (!culture.cells) return tip("Culture does not have any cells, cannot change population", false, "error");
|
||||
|
||||
const rural = rn(culture.rural * populationRate);
|
||||
const urban = rn(culture.urban * populationRate * urbanization);
|
||||
const total = rural + urban;
|
||||
const format = n => Number(n).toLocaleString();
|
||||
const burgs = pack.burgs.filter(b => !b.removed && b.culture === cultureId);
|
||||
|
||||
alertMessage.innerHTML = /* html */ `<div>
|
||||
<i>Change population of all cells assigned to the culture</i>
|
||||
<div style="margin: 0.5em 0">
|
||||
Rural: <input type="number" min="0" step="1" id="ruralPop" value=${rural} style="width:6em" />
|
||||
Urban: <input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em"
|
||||
${burgs.length ? "" : "disabled"} />
|
||||
</div>
|
||||
<div>Total population: ${format(total)} ⇒ <span id="totalPop">${format(total)}</span>
|
||||
(<span id="totalPopPerc">100</span>%)
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const update = function () {
|
||||
const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber;
|
||||
if (isNaN(totalNew)) return;
|
||||
totalPop.innerHTML = l(totalNew);
|
||||
totalPopPerc.innerHTML = rn((totalNew / total) * 100);
|
||||
};
|
||||
|
||||
ruralPop.oninput = () => update();
|
||||
urbanPop.oninput = () => update();
|
||||
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
title: "Change culture population",
|
||||
width: "24em",
|
||||
buttons: {
|
||||
Apply: function () {
|
||||
applyPopulationChange(rural, urban, ruralPop.value, urbanPop.value, cultureId);
|
||||
$(this).dialog("close");
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
},
|
||||
position: {my: "center", at: "center", of: "svg"}
|
||||
});
|
||||
}
|
||||
|
||||
function applyPopulationChange(oldRural, oldUrban, newRural, newUrban, culture) {
|
||||
const ruralChange = newRural / oldRural;
|
||||
if (isFinite(ruralChange) && ruralChange !== 1) {
|
||||
const cells = pack.cells.i.filter(i => pack.cells.culture[i] === culture);
|
||||
cells.forEach(i => (pack.cells.pop[i] *= ruralChange));
|
||||
}
|
||||
if (!isFinite(ruralChange) && +newRural > 0) {
|
||||
const points = newRural / populationRate;
|
||||
const cells = pack.cells.i.filter(i => pack.cells.culture[i] === culture);
|
||||
const pop = rn(points / cells.length);
|
||||
cells.forEach(i => (pack.cells.pop[i] = pop));
|
||||
}
|
||||
|
||||
const burgs = pack.burgs.filter(b => !b.removed && b.culture === culture);
|
||||
const urbanChange = newUrban / oldUrban;
|
||||
if (isFinite(urbanChange) && urbanChange !== 1) {
|
||||
burgs.forEach(b => (b.population = rn(b.population * urbanChange, 4)));
|
||||
}
|
||||
if (!isFinite(urbanChange) && +newUrban > 0) {
|
||||
const points = newUrban / populationRate / urbanization;
|
||||
const population = rn(points / burgs.length, 4);
|
||||
burgs.forEach(b => (b.population = population));
|
||||
}
|
||||
|
||||
refreshCulturesEditor();
|
||||
}
|
||||
|
||||
function cultureRegenerateBurgs() {
|
||||
if (customization === 4) return;
|
||||
|
||||
const cultureId = +this.parentNode.dataset.id;
|
||||
const cBurgs = pack.burgs.filter(b => b.culture === cultureId && !b.lock);
|
||||
cBurgs.forEach(b => {
|
||||
b.name = Names.getCulture(cultureId);
|
||||
labels.select("[data-id='" + b.i + "']").text(b.name);
|
||||
});
|
||||
tip(`Names for ${cBurgs.length} burgs are regenerated`, false, "success");
|
||||
}
|
||||
|
||||
function removeCulture(cultureId) {
|
||||
cults.select("#culture" + cultureId).remove();
|
||||
debug.select("#cultureCenter" + cultureId).remove();
|
||||
|
||||
const {burgs, states, cells, cultures} = pack;
|
||||
|
||||
burgs.filter(b => b.culture == cultureId).forEach(b => (b.culture = 0));
|
||||
states.forEach(s => {
|
||||
if (s.culture === cultureId) s.culture = 0;
|
||||
});
|
||||
cells.culture.forEach((c, i) => {
|
||||
if (c === cultureId) cells.culture[i] = 0;
|
||||
});
|
||||
cultures[cultureId].removed = true;
|
||||
|
||||
cultures
|
||||
.filter(c => c.i && !c.removed)
|
||||
.forEach(c => {
|
||||
c.origins = c.origins.filter(origin => origin !== cultureId);
|
||||
if (!c.origins.length) c.origins = [0];
|
||||
});
|
||||
refreshCulturesEditor();
|
||||
}
|
||||
|
||||
function cultureRemovePrompt() {
|
||||
if (customization) return;
|
||||
|
||||
const cultureId = +this.parentNode.dataset.id;
|
||||
confirmationDialog({
|
||||
title: "Remove culture",
|
||||
message: "Are you sure you want to remove the culture? <br>This action cannot be reverted",
|
||||
confirm: "Remove",
|
||||
onConfirm: () => removeCulture(cultureId)
|
||||
});
|
||||
}
|
||||
|
||||
function drawCultureCenters() {
|
||||
const tooltip = "Drag to move the culture center (ancestral home)";
|
||||
debug.select("#cultureCenters").remove();
|
||||
const cultureCenters = debug
|
||||
.append("g")
|
||||
.attr("id", "cultureCenters")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#444444")
|
||||
.style("cursor", "move");
|
||||
|
||||
const data = pack.cultures.filter(c => c.i && !c.removed);
|
||||
cultureCenters
|
||||
.selectAll("circle")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("id", d => "cultureCenter" + d.i)
|
||||
.attr("data-id", d => d.i)
|
||||
.attr("r", 6)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("cx", d => pack.cells.p[d.center][0])
|
||||
.attr("cy", d => pack.cells.p[d.center][1])
|
||||
.on("mouseenter", d => {
|
||||
tip(tooltip, true);
|
||||
$body.querySelector(`div[data-id='${d.i}']`).classList.add("selected");
|
||||
cultureHighlightOn(event);
|
||||
})
|
||||
.on("mouseleave", d => {
|
||||
tip("", true);
|
||||
$body.querySelector(`div[data-id='${d.i}']`).classList.remove("selected");
|
||||
cultureHighlightOff(event);
|
||||
})
|
||||
.call(d3.drag().on("start", cultureCenterDrag));
|
||||
}
|
||||
|
||||
function cultureCenterDrag() {
|
||||
const $el = d3.select(this);
|
||||
const cultureId = +this.id.slice(13);
|
||||
d3.event.on("drag", () => {
|
||||
const {x, y} = d3.event;
|
||||
$el.attr("cx", x).attr("cy", y);
|
||||
const cell = findCell(x, y);
|
||||
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
|
||||
pack.cultures[cultureId].center = cell;
|
||||
recalculateCultures();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleLegend() {
|
||||
if (legend.selectAll("*").size()) return clearLegend();
|
||||
|
||||
const data = pack.cultures
|
||||
.filter(c => c.i && !c.removed && c.cells)
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.map(c => [c.i, c.color, c.name]);
|
||||
drawLegend("Cultures", data);
|
||||
}
|
||||
|
||||
function togglePercentageMode() {
|
||||
if ($body.dataset.type === "absolute") {
|
||||
$body.dataset.type = "percentage";
|
||||
const totalCells = +byId("culturesFooterCells").innerText;
|
||||
const totalArea = +byId("culturesFooterArea").dataset.area;
|
||||
const totalPopulation = +byId("culturesFooterPopulation").dataset.population;
|
||||
|
||||
$body.querySelectorAll(":scope > div").forEach(function (el) {
|
||||
const {cells, area, population} = el.dataset;
|
||||
el.querySelector(".cultureCells").innerText = rn((+cells / totalCells) * 100) + "%";
|
||||
el.querySelector(".cultureArea").innerText = rn((+area / totalArea) * 100) + "%";
|
||||
el.querySelector(".culturePopulation").innerText = rn((+population / totalPopulation) * 100) + "%";
|
||||
});
|
||||
} else {
|
||||
$body.dataset.type = "absolute";
|
||||
culturesEditorAddLines();
|
||||
}
|
||||
}
|
||||
|
||||
async function showHierarchy() {
|
||||
if (customization) return;
|
||||
const HeirarchyTree = await import("../hierarchy-tree.js?v=1.87.01");
|
||||
|
||||
const getDescription = culture => {
|
||||
const {name, type, rural, urban} = culture;
|
||||
|
||||
const population = rural * populationRate + urban * populationRate * urbanization;
|
||||
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
|
||||
return `${name} culture. ${type}. ${populationText}`;
|
||||
};
|
||||
|
||||
const getShape = ({type}) => {
|
||||
if (type === "Generic") return "circle";
|
||||
if (type === "River") return "diamond";
|
||||
if (type === "Lake") return "hexagon";
|
||||
if (type === "Naval") return "square";
|
||||
if (type === "Highland") return "concave";
|
||||
if (type === "Nomadic") return "octagon";
|
||||
if (type === "Hunting") return "pentagon";
|
||||
};
|
||||
|
||||
HeirarchyTree.open({
|
||||
type: "cultures",
|
||||
data: pack.cultures,
|
||||
onNodeEnter: cultureHighlightOn,
|
||||
onNodeLeave: cultureHighlightOff,
|
||||
getDescription,
|
||||
getShape
|
||||
});
|
||||
}
|
||||
|
||||
function recalculateCultures(must) {
|
||||
if (!must && !culturesAutoChange.checked) return;
|
||||
|
||||
pack.cells.culture = new Uint16Array(pack.cells.i.length);
|
||||
pack.cultures.forEach(function (c) {
|
||||
if (!c.i || c.removed) return;
|
||||
pack.cells.culture[c.center] = c.i;
|
||||
});
|
||||
|
||||
Cultures.expand();
|
||||
drawCultures();
|
||||
pack.burgs.forEach(b => (b.culture = pack.cells.culture[b.cell]));
|
||||
refreshCulturesEditor();
|
||||
document.querySelector("input.cultureExpan").focus(); // to not trigger hotkeys
|
||||
}
|
||||
|
||||
function enterCultureManualAssignent() {
|
||||
if (!layerIsOn("toggleCultures")) toggleCultures();
|
||||
customization = 4;
|
||||
cults.append("g").attr("id", "temp");
|
||||
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "none"));
|
||||
byId("culturesManuallyButtons").style.display = "inline-block";
|
||||
debug.select("#cultureCenters").style("display", "none");
|
||||
|
||||
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
|
||||
culturesFooter.style.display = "none";
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
|
||||
$("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
|
||||
|
||||
tip("Click on culture to select, drag the circle to change culture", true);
|
||||
viewbox
|
||||
.style("cursor", "crosshair")
|
||||
.on("click", selectCultureOnMapClick)
|
||||
.call(d3.drag().on("start", dragCultureBrush))
|
||||
.on("touchmove mousemove", moveCultureBrush);
|
||||
|
||||
$body.querySelector("div").classList.add("selected");
|
||||
}
|
||||
|
||||
function selectCultureOnLineClick(i) {
|
||||
if (customization !== 4) return;
|
||||
$body.querySelector("div.selected").classList.remove("selected");
|
||||
this.classList.add("selected");
|
||||
}
|
||||
|
||||
function selectCultureOnMapClick() {
|
||||
const point = d3.mouse(this);
|
||||
const i = findCell(point[0], point[1]);
|
||||
if (pack.cells.h[i] < 20) return;
|
||||
|
||||
const assigned = cults.select("#temp").select("polygon[data-cell='" + i + "']");
|
||||
const culture = assigned.size() ? +assigned.attr("data-culture") : pack.cells.culture[i];
|
||||
|
||||
$body.querySelector("div.selected").classList.remove("selected");
|
||||
$body.querySelector("div[data-id='" + culture + "']").classList.add("selected");
|
||||
}
|
||||
|
||||
function dragCultureBrush() {
|
||||
const radius = +culturesManuallyBrush.value;
|
||||
|
||||
d3.event.on("drag", () => {
|
||||
if (!d3.event.dx && !d3.event.dy) return;
|
||||
const p = d3.mouse(this);
|
||||
moveCircle(p[0], p[1], radius);
|
||||
|
||||
const found = radius > 5 ? findAll(p[0], p[1], radius) : [findCell(p[0], p[1], radius)];
|
||||
const selection = found.filter(isLand);
|
||||
if (selection) changeCultureForSelection(selection);
|
||||
});
|
||||
}
|
||||
|
||||
function changeCultureForSelection(selection) {
|
||||
const temp = cults.select("#temp");
|
||||
const selected = $body.querySelector("div.selected");
|
||||
|
||||
const cultureNew = +selected.dataset.id;
|
||||
const color = pack.cultures[cultureNew].color || "#ffffff";
|
||||
|
||||
selection.forEach(function (i) {
|
||||
const exists = temp.select("polygon[data-cell='" + i + "']");
|
||||
const cultureOld = exists.size() ? +exists.attr("data-culture") : pack.cells.culture[i];
|
||||
if (cultureNew === cultureOld) return;
|
||||
|
||||
// change of append new element
|
||||
if (exists.size()) exists.attr("data-culture", cultureNew).attr("fill", color).attr("stroke", color);
|
||||
else
|
||||
temp
|
||||
.append("polygon")
|
||||
.attr("data-cell", i)
|
||||
.attr("data-culture", cultureNew)
|
||||
.attr("points", getPackPolygon(i))
|
||||
.attr("fill", color)
|
||||
.attr("stroke", color);
|
||||
});
|
||||
}
|
||||
|
||||
function moveCultureBrush() {
|
||||
showMainTip();
|
||||
const point = d3.mouse(this);
|
||||
const radius = +culturesManuallyBrush.value;
|
||||
moveCircle(point[0], point[1], radius);
|
||||
}
|
||||
|
||||
function applyCultureManualAssignent() {
|
||||
const changed = cults.select("#temp").selectAll("polygon");
|
||||
changed.each(function () {
|
||||
const i = +this.dataset.cell;
|
||||
const c = +this.dataset.culture;
|
||||
pack.cells.culture[i] = c;
|
||||
if (pack.cells.burg[i]) pack.burgs[pack.cells.burg[i]].culture = c;
|
||||
});
|
||||
|
||||
if (changed.size()) {
|
||||
drawCultures();
|
||||
refreshCulturesEditor();
|
||||
}
|
||||
exitCulturesManualAssignment();
|
||||
}
|
||||
|
||||
function exitCulturesManualAssignment(close) {
|
||||
customization = 0;
|
||||
cults.select("#temp").remove();
|
||||
removeCircle();
|
||||
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "inline-block"));
|
||||
byId("culturesManuallyButtons").style.display = "none";
|
||||
|
||||
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.remove("hidden"));
|
||||
culturesFooter.style.display = "block";
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
|
||||
if (!close) $("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
|
||||
|
||||
debug.select("#cultureCenters").style("display", null);
|
||||
restoreDefaultEvents();
|
||||
clearMainTip();
|
||||
const selected = $body.querySelector("div.selected");
|
||||
if (selected) selected.classList.remove("selected");
|
||||
}
|
||||
|
||||
function enterAddCulturesMode() {
|
||||
if (this.classList.contains("pressed")) return exitAddCultureMode();
|
||||
|
||||
customization = 9;
|
||||
this.classList.add("pressed");
|
||||
tip("Click on the map to add a new culture", true);
|
||||
viewbox.style("cursor", "crosshair").on("click", addCulture);
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
|
||||
}
|
||||
|
||||
function exitAddCultureMode() {
|
||||
customization = 0;
|
||||
restoreDefaultEvents();
|
||||
clearMainTip();
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
|
||||
if (culturesAdd.classList.contains("pressed")) culturesAdd.classList.remove("pressed");
|
||||
}
|
||||
|
||||
function addCulture() {
|
||||
const point = d3.mouse(this);
|
||||
const center = findCell(point[0], point[1]);
|
||||
|
||||
if (pack.cells.h[center] < 20)
|
||||
return tip("You cannot place culture center into the water. Please click on a land cell", false, "error");
|
||||
const occupied = pack.cultures.some(c => !c.removed && c.center === center);
|
||||
if (occupied) return tip("This cell is already a culture center. Please select a different cell", false, "error");
|
||||
|
||||
if (d3.event.shiftKey === false) exitAddCultureMode();
|
||||
Cultures.add(center);
|
||||
|
||||
drawCultureCenters();
|
||||
culturesEditorAddLines();
|
||||
}
|
||||
|
||||
function downloadCulturesCsv() {
|
||||
const unit = getAreaUnit("2");
|
||||
const headers = `Id,Name,Color,Cells,Expansionism,Type,Area ${unit},Population,Namesbase,Emblems Shape,Origins`;
|
||||
const lines = Array.from($body.querySelectorAll(":scope > div"));
|
||||
const data = lines.map($line => {
|
||||
const {id, name, color, cells, expansionism, type, area, population, emblems, base} = $line.dataset;
|
||||
const namesbase = nameBases[+base].name;
|
||||
const {origins} = pack.cultures[+id];
|
||||
const originList = origins.filter(origin => origin).map(origin => pack.cultures[origin].name);
|
||||
const originText = '"' + originList.join(", ") + '"';
|
||||
return [id, name, color, cells, expansionism, type, area, population, namesbase, emblems, originText].join(",");
|
||||
});
|
||||
const csvData = [headers].concat(data).join("\n");
|
||||
|
||||
const name = getFileName("Cultures") + ".csv";
|
||||
downloadFile(csvData, name);
|
||||
}
|
||||
|
||||
function closeCulturesEditor() {
|
||||
debug.select("#cultureCenters").remove();
|
||||
exitCulturesManualAssignment("close");
|
||||
exitAddCultureMode();
|
||||
}
|
||||
|
||||
async function uploadCulturesData() {
|
||||
const csv = await Formats.csvParser(this.files[0]);
|
||||
this.value = "";
|
||||
|
||||
const {cultures, cells} = pack;
|
||||
const shapes = Object.keys(COA.shields.types)
|
||||
.map(type => Object.keys(COA.shields[type]))
|
||||
.flat();
|
||||
|
||||
const populated = cells.pop.map((c, i) => (c ? i : null)).filter(c => c);
|
||||
cultures.forEach(item => {
|
||||
if (item.i) item.removed = true;
|
||||
});
|
||||
|
||||
for (const c of csv.iterator((a, b) => +a[0] > +b[0])) {
|
||||
let current;
|
||||
if (+c.id < cultures.length) {
|
||||
current = cultures[c.id];
|
||||
|
||||
const ratio = current.urban / (current.rural + current.urban);
|
||||
applyPopulationChange(current.rural, current.urban, c.population * (1 - ratio), c.population * ratio, +c.id);
|
||||
} else {
|
||||
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origin: 0, rural: 0, urban: 0};
|
||||
cultures.push(current);
|
||||
}
|
||||
|
||||
current.removed = false;
|
||||
current.name = c.culture;
|
||||
current.code = abbreviate(
|
||||
current.name,
|
||||
cultures.map(c => c.code)
|
||||
);
|
||||
|
||||
current.color = c.color;
|
||||
current.expansionism = +c.expansionism;
|
||||
current.origins = JSON.parse(c.origins);
|
||||
|
||||
if (cultureTypes.includes(c.type)) current.type = c.type;
|
||||
else current.type = "Generic";
|
||||
|
||||
const shieldShape = c["emblems shape"].toLowerCase();
|
||||
if (shapes.includes(shieldShape)) current.shield = shieldShape;
|
||||
else current.shield = "heater";
|
||||
|
||||
const nameBaseIndex = nameBases.findIndex(n => n.name == c.namesbase);
|
||||
current.base = nameBaseIndex === -1 ? 0 : nameBaseIndex;
|
||||
}
|
||||
|
||||
cultures.filter(c => c.removed).forEach(c => removeCulture(c.i));
|
||||
|
||||
drawCultures();
|
||||
refreshCulturesEditor();
|
||||
}
|
||||
766
src/modules/dynamic/editors/religions-editor.js
Normal file
766
src/modules/dynamic/editors/religions-editor.js
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
import {restoreDefaultEvents} from "/src/scripts/events";
|
||||
import {findAll, findCell, getPackPolygon, isLand} from "/src/utils/graphUtils";
|
||||
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
|
||||
import {byId} from "/src/utils/shorthands";
|
||||
import {rn} from "/src/utils/numberUtils";
|
||||
import {si} from "/src/utils/unitUtils";
|
||||
import {abbreviate} from "/src/utils/languageUtils";
|
||||
import {debounce} from "/src/utils/functionUtils";
|
||||
|
||||
const $body = insertEditorHtml();
|
||||
addListeners();
|
||||
|
||||
export function open() {
|
||||
closeDialogs("#religionsEditor, .stable");
|
||||
if (!layerIsOn("toggleReligions")) toggleCultures();
|
||||
if (layerIsOn("toggleStates")) toggleStates();
|
||||
if (layerIsOn("toggleBiomes")) toggleBiomes();
|
||||
if (layerIsOn("toggleCultures")) toggleReligions();
|
||||
if (layerIsOn("toggleProvinces")) toggleProvinces();
|
||||
|
||||
refreshReligionsEditor();
|
||||
drawReligionCenters();
|
||||
|
||||
$("#religionsEditor").dialog({
|
||||
title: "Religions Editor",
|
||||
resizable: false,
|
||||
close: closeReligionsEditor,
|
||||
position: {my: "right top", at: "right-10 top+10", of: "svg"}
|
||||
});
|
||||
$body.focus();
|
||||
}
|
||||
|
||||
function insertEditorHtml() {
|
||||
const editorHtml = /* html */ `<div id="religionsEditor" class="dialog stable">
|
||||
<div id="religionsHeader" class="header" style="grid-template-columns: 13em 6em 7em 18em 5em 6em">
|
||||
<div data-tip="Click to sort by religion name" class="sortable alphabetically" data-sortby="name">Religion </div>
|
||||
<div data-tip="Click to sort by religion type" class="sortable alphabetically icon-sort-name-down" data-sortby="type">Type </div>
|
||||
<div data-tip="Click to sort by religion form" class="sortable alphabetically hide" data-sortby="form">Form </div>
|
||||
<div data-tip="Click to sort by supreme deity" class="sortable alphabetically hide" data-sortby="deity">Supreme Deity </div>
|
||||
<div data-tip="Click to sort by religion area" class="sortable hide" data-sortby="area">Area </div>
|
||||
<div data-tip="Click to sort by number of believers (religion area population)" class="sortable hide" data-sortby="population">Believers </div>
|
||||
</div>
|
||||
<div id="religionsBody" class="table" data-type="absolute"></div>
|
||||
|
||||
<div id="religionsFooter" class="totalLine">
|
||||
<div data-tip="Total number of organized religions" style="margin-left: 12px">
|
||||
Organized: <span id="religionsOrganized">0</span>
|
||||
</div>
|
||||
<div data-tip="Total number of heresies" style="margin-left: 12px">
|
||||
Heresies: <span id="religionsHeresies">0</span>
|
||||
</div>
|
||||
<div data-tip="Total number of cults" style="margin-left: 12px">
|
||||
Cults: <span id="religionsCults">0</span>
|
||||
</div>
|
||||
<div data-tip="Total number of folk religions" style="margin-left: 12px">
|
||||
Folk: <span id="religionsFolk">0</span>
|
||||
</div>
|
||||
<div data-tip="Total land area" style="margin-left: 12px">
|
||||
Land Area: <span id="religionsFooterArea">0</span>
|
||||
</div>
|
||||
<div data-tip="Total number of believers (population)" style="margin-left: 12px">
|
||||
Believers: <span id="religionsFooterPopulation">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="religionsBottom">
|
||||
<button id="religionsEditorRefresh" data-tip="Refresh the Editor" class="icon-cw"></button>
|
||||
<button id="religionsEditStyle" data-tip="Edit religions style in Style Editor" class="icon-adjust"></button>
|
||||
<button id="religionsLegend" data-tip="Toggle Legend box" class="icon-list-bullet"></button>
|
||||
<button id="religionsPercentage" data-tip="Toggle percentage / absolute values display mode" class="icon-percent"></button>
|
||||
<button id="religionsHeirarchy" data-tip="Show religions hierarchy tree" class="icon-sitemap"></button>
|
||||
<button id="religionsExtinct" data-tip="Show/hide extinct religions (religions without cells)" class="icon-eye-off"></button>
|
||||
|
||||
<button id="religionsManually" data-tip="Manually re-assign religions" class="icon-brush"></button>
|
||||
<div id="religionsManuallyButtons" style="display: none">
|
||||
<label data-tip="Change brush size" data-shortcut="+ (increase), – (decrease)" class="italic">Brush size:
|
||||
<input
|
||||
id="religionsManuallyBrush"
|
||||
oninput="tip('Brush size: '+this.value); religionsManuallyBrushNumber.value = this.value"
|
||||
type="range"
|
||||
min="5"
|
||||
max="99"
|
||||
value="15"
|
||||
style="width: 7em"
|
||||
/>
|
||||
<input
|
||||
id="religionsManuallyBrushNumber"
|
||||
oninput="tip('Brush size: '+this.value); religionsManuallyBrush.value = this.value"
|
||||
type="number"
|
||||
min="5"
|
||||
max="99"
|
||||
value="15"
|
||||
/> </label
|
||||
><br />
|
||||
<button id="religionsManuallyApply" data-tip="Apply assignment" class="icon-check"></button>
|
||||
<button id="religionsManuallyCancel" data-tip="Cancel assignment" class="icon-cancel"></button>
|
||||
</div>
|
||||
<button id="religionsAdd" data-tip="Add a new religion. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button id="religionsExport" data-tip="Download religions-related data" class="icon-download"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
byId("dialogs").insertAdjacentHTML("beforeend", editorHtml);
|
||||
return byId("religionsBody");
|
||||
}
|
||||
|
||||
function addListeners() {
|
||||
applySortingByHeader("religionsHeader");
|
||||
|
||||
byId("religionsEditorRefresh").on("click", refreshReligionsEditor);
|
||||
byId("religionsEditStyle").on("click", () => editStyle("relig"));
|
||||
byId("religionsLegend").on("click", toggleLegend);
|
||||
byId("religionsPercentage").on("click", togglePercentageMode);
|
||||
byId("religionsHeirarchy").on("click", showHierarchy);
|
||||
byId("religionsExtinct").on("click", toggleExtinct);
|
||||
byId("religionsManually").on("click", enterReligionsManualAssignent);
|
||||
byId("religionsManuallyApply").on("click", applyReligionsManualAssignent);
|
||||
byId("religionsManuallyCancel").on("click", () => exitReligionsManualAssignment());
|
||||
byId("religionsAdd").on("click", enterAddReligionMode);
|
||||
byId("religionsExport").on("click", downloadReligionsCsv);
|
||||
}
|
||||
|
||||
function refreshReligionsEditor() {
|
||||
religionsCollectStatistics();
|
||||
religionsEditorAddLines();
|
||||
}
|
||||
|
||||
function religionsCollectStatistics() {
|
||||
const {cells, religions, burgs} = pack;
|
||||
religions.forEach(r => {
|
||||
r.cells = r.area = r.rural = r.urban = 0;
|
||||
});
|
||||
|
||||
for (const i of cells.i) {
|
||||
if (cells.h[i] < 20) continue;
|
||||
const religionId = cells.religion[i];
|
||||
religions[religionId].cells += 1;
|
||||
religions[religionId].area += cells.area[i];
|
||||
religions[religionId].rural += cells.pop[i];
|
||||
const burgId = cells.burg[i];
|
||||
if (burgId) religions[religionId].urban += burgs[burgId].population;
|
||||
}
|
||||
}
|
||||
|
||||
// add line for each religion
|
||||
function religionsEditorAddLines() {
|
||||
const unit = " " + getAreaUnit();
|
||||
let lines = "";
|
||||
let totalArea = 0;
|
||||
let totalPopulation = 0;
|
||||
|
||||
for (const r of pack.religions) {
|
||||
if (r.removed) continue;
|
||||
if (r.i && !r.cells && $body.dataset.extinct !== "show") continue; // hide extinct religions
|
||||
|
||||
const area = getArea(r.area);
|
||||
const rural = r.rural * populationRate;
|
||||
const urban = r.urban * populationRate * urbanization;
|
||||
const population = rn(rural + urban);
|
||||
const populationTip = `Believers: ${si(population)}; Rural areas: ${si(rural)}; Urban areas: ${si(
|
||||
urban
|
||||
)}. Click to change`;
|
||||
totalArea += area;
|
||||
totalPopulation += population;
|
||||
|
||||
if (!r.i) {
|
||||
// No religion (neutral) line
|
||||
lines += /* html */ `<div
|
||||
class="states"
|
||||
data-id="${r.i}"
|
||||
data-name="${r.name}"
|
||||
data-color=""
|
||||
data-area="${area}"
|
||||
data-population="${population}"
|
||||
data-type=""
|
||||
data-form=""
|
||||
data-deity=""
|
||||
data-expansionism=""
|
||||
>
|
||||
<svg width="11" height="11" class="placeholder"></svg>
|
||||
<input data-tip="Religion name. Click and type to change" class="religionName italic" style="width: 11em"
|
||||
value="${r.name}" autocorrect="off" spellcheck="false" />
|
||||
<select data-tip="Religion type" class="religionType placeholder" style="width: 5em">
|
||||
${getTypeOptions(r.type)}
|
||||
</select>
|
||||
<input data-tip="Religion form" class="religionForm placeholder hide" style="width: 6em" value="" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Click to re-generate supreme deity" class="icon-arrows-cw placeholder hide"></span>
|
||||
<input data-tip="Religion supreme deity" class="religionDeity placeholder hide" style="width: 17em" value="" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Religion area" style="padding-right: 4px" class="icon-map-o hide"></span>
|
||||
<div data-tip="Religion area" class="religionArea hide" style="width: 5em">${si(area) + unit}</div>
|
||||
<span data-tip="${populationTip}" class="icon-male hide"></span>
|
||||
<div data-tip="${populationTip}" class="religionPopulation hide pointer">${si(population)}</div>
|
||||
</div>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines += /* html */ `<div
|
||||
class="states"
|
||||
data-id=${r.i}
|
||||
data-name="${r.name}"
|
||||
data-color="${r.color}"
|
||||
data-area=${area}
|
||||
data-population=${population}
|
||||
data-type="${r.type}"
|
||||
data-form="${r.form}"
|
||||
data-deity="${r.deity || ""}"
|
||||
data-expansionism="${r.expansionism}"
|
||||
>
|
||||
<fill-box fill="${r.color}"></fill-box>
|
||||
<input data-tip="Religion name. Click and type to change" class="religionName" style="width: 11em"
|
||||
value="${r.name}" autocorrect="off" spellcheck="false" />
|
||||
<select data-tip="Religion type" class="religionType" style="width: 5em">
|
||||
${getTypeOptions(r.type)}
|
||||
</select>
|
||||
<input data-tip="Religion form" class="religionForm hide" style="width: 6em"
|
||||
value="${r.form}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Click to re-generate supreme deity" class="icon-arrows-cw hide"></span>
|
||||
<input data-tip="Religion supreme deity" class="religionDeity hide" style="width: 17em"
|
||||
value="${r.deity || ""}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Religion area" style="padding-right: 4px" class="icon-map-o hide"></span>
|
||||
<div data-tip="Religion area" class="religionArea hide" style="width: 5em">${si(area) + unit}</div>
|
||||
<span data-tip="${populationTip}" class="icon-male hide"></span>
|
||||
<div data-tip="${populationTip}" class="religionPopulation hide pointer">${si(population)}</div>
|
||||
<span data-tip="Remove religion" class="icon-trash-empty hide"></span>
|
||||
</div>`;
|
||||
}
|
||||
$body.innerHTML = lines;
|
||||
|
||||
// update footer
|
||||
const validReligions = pack.religions.filter(r => r.i && !r.removed);
|
||||
byId("religionsOrganized").innerHTML = validReligions.filter(r => r.type === "Organized").length;
|
||||
byId("religionsHeresies").innerHTML = validReligions.filter(r => r.type === "Heresy").length;
|
||||
byId("religionsCults").innerHTML = validReligions.filter(r => r.type === "Cult").length;
|
||||
byId("religionsFolk").innerHTML = validReligions.filter(r => r.type === "Folk").length;
|
||||
byId("religionsFooterArea").innerHTML = si(totalArea) + unit;
|
||||
byId("religionsFooterPopulation").innerHTML = si(totalPopulation);
|
||||
byId("religionsFooterArea").dataset.area = totalArea;
|
||||
byId("religionsFooterPopulation").dataset.population = totalPopulation;
|
||||
|
||||
// add listeners
|
||||
$body.querySelectorAll(":scope > div").forEach($line => {
|
||||
$line.on("mouseenter", religionHighlightOn);
|
||||
$line.on("mouseleave", religionHighlightOff);
|
||||
$line.on("click", selectReligionOnLineClick);
|
||||
});
|
||||
$body.querySelectorAll("fill-box").forEach(el => el.on("click", religionChangeColor));
|
||||
$body.querySelectorAll("div > input.religionName").forEach(el => el.on("input", religionChangeName));
|
||||
$body.querySelectorAll("div > select.religionType").forEach(el => el.on("change", religionChangeType));
|
||||
$body.querySelectorAll("div > input.religionForm").forEach(el => el.on("input", religionChangeForm));
|
||||
$body.querySelectorAll("div > input.religionDeity").forEach(el => el.on("input", religionChangeDeity));
|
||||
$body.querySelectorAll("div > span.icon-arrows-cw").forEach(el => el.on("click", regenerateDeity));
|
||||
$body.querySelectorAll("div > div.religionPopulation").forEach(el => el.on("click", changePopulation));
|
||||
$body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", religionRemovePrompt));
|
||||
|
||||
if ($body.dataset.type === "percentage") {
|
||||
$body.dataset.type = "absolute";
|
||||
togglePercentageMode();
|
||||
}
|
||||
applySorting(religionsHeader);
|
||||
$("#religionsEditor").dialog({width: "fit-content"});
|
||||
}
|
||||
|
||||
function getTypeOptions(type) {
|
||||
let options = "";
|
||||
const types = ["Folk", "Organized", "Cult", "Heresy"];
|
||||
types.forEach(t => (options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`));
|
||||
return options;
|
||||
}
|
||||
|
||||
const religionHighlightOn = debounce(event => {
|
||||
const religionId = Number(event.id || event.target.dataset.id);
|
||||
const $el = $body.querySelector(`div[data-id='${religionId}']`);
|
||||
if ($el) $el.classList.add("active");
|
||||
|
||||
if (!layerIsOn("toggleReligions")) return;
|
||||
if (customization) return;
|
||||
|
||||
const animate = d3.transition().duration(1500).ease(d3.easeSinIn);
|
||||
relig
|
||||
.select("#religion" + religionId)
|
||||
.raise()
|
||||
.transition(animate)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("stroke", "#c13119");
|
||||
debug
|
||||
.select("#religionsCenter" + religionId)
|
||||
.raise()
|
||||
.transition(animate)
|
||||
.attr("r", 8)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke", "#c13119");
|
||||
}, 200);
|
||||
|
||||
function religionHighlightOff(event) {
|
||||
const religionId = Number(event.id || event.target.dataset.id);
|
||||
const $el = $body.querySelector(`div[data-id='${religionId}']`);
|
||||
if ($el) $el.classList.remove("active");
|
||||
|
||||
relig
|
||||
.select("#religion" + religionId)
|
||||
.transition()
|
||||
.attr("stroke-width", null)
|
||||
.attr("stroke", null);
|
||||
debug
|
||||
.select("#religionsCenter" + religionId)
|
||||
.transition()
|
||||
.attr("r", 4)
|
||||
.attr("stroke-width", 1.2)
|
||||
.attr("stroke", null);
|
||||
}
|
||||
|
||||
function religionChangeColor() {
|
||||
const $el = this;
|
||||
const currentFill = $el.getAttribute("fill");
|
||||
const religionId = +$el.parentNode.dataset.id;
|
||||
|
||||
const callback = newFill => {
|
||||
$el.fill = newFill;
|
||||
pack.religions[religionId].color = newFill;
|
||||
relig.select("#religion" + religionId).attr("fill", newFill);
|
||||
debug.select("#religionsCenter" + religionId).attr("fill", newFill);
|
||||
};
|
||||
|
||||
openPicker(currentFill, callback);
|
||||
}
|
||||
|
||||
function religionChangeName() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.name = this.value;
|
||||
pack.religions[religionId].name = this.value;
|
||||
pack.religions[religionId].code = abbreviate(
|
||||
this.value,
|
||||
pack.religions.map(c => c.code)
|
||||
);
|
||||
}
|
||||
|
||||
function religionChangeType() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.type = this.value;
|
||||
pack.religions[religionId].type = this.value;
|
||||
}
|
||||
|
||||
function religionChangeForm() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.form = this.value;
|
||||
pack.religions[religionId].form = this.value;
|
||||
}
|
||||
|
||||
function religionChangeDeity() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
this.parentNode.dataset.deity = this.value;
|
||||
pack.religions[religionId].deity = this.value;
|
||||
}
|
||||
|
||||
function regenerateDeity() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
const cultureId = pack.religions[religionId].culture;
|
||||
const deity = Religions.getDeityName(cultureId);
|
||||
this.parentNode.dataset.deity = deity;
|
||||
pack.religions[religionId].deity = deity;
|
||||
this.nextElementSibling.value = deity;
|
||||
}
|
||||
|
||||
function changePopulation() {
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
const religion = pack.religions[religionId];
|
||||
if (!religion.cells) return tip("Religion does not have any cells, cannot change population", false, "error");
|
||||
|
||||
const rural = rn(religion.rural * populationRate);
|
||||
const urban = rn(religion.urban * populationRate * urbanization);
|
||||
const total = rural + urban;
|
||||
const format = n => Number(n).toLocaleString();
|
||||
const burgs = pack.burgs.filter(b => !b.removed && pack.cells.religion[b.cell] === religionId);
|
||||
|
||||
alertMessage.innerHTML = /* html */ `<div>
|
||||
<i>All population of religion territory is considered believers of this religion. It means believers number change will directly affect population</i>
|
||||
<div style="margin: 0.5em 0">
|
||||
Rural: <input type="number" min="0" step="1" id="ruralPop" value=${rural} style="width:6em" />
|
||||
Urban: <input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em"
|
||||
${burgs.length ? "" : "disabled"} />
|
||||
</div>
|
||||
<div>Total population: ${format(total)} ⇒ <span id="totalPop">${format(total)}</span>
|
||||
(<span id="totalPopPerc">100</span>%)
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const update = function () {
|
||||
const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber;
|
||||
if (isNaN(totalNew)) return;
|
||||
totalPop.innerHTML = format(totalNew);
|
||||
totalPopPerc.innerHTML = rn((totalNew / total) * 100);
|
||||
};
|
||||
|
||||
ruralPop.oninput = () => update();
|
||||
urbanPop.oninput = () => update();
|
||||
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
title: "Change believers number",
|
||||
width: "24em",
|
||||
buttons: {
|
||||
Apply: function () {
|
||||
applyPopulationChange();
|
||||
$(this).dialog("close");
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
},
|
||||
position: {my: "center", at: "center", of: "svg"}
|
||||
});
|
||||
|
||||
function applyPopulationChange() {
|
||||
const ruralChange = ruralPop.value / rural;
|
||||
if (isFinite(ruralChange) && ruralChange !== 1) {
|
||||
const cells = pack.cells.i.filter(i => pack.cells.religion[i] === religionId);
|
||||
cells.forEach(i => (pack.cells.pop[i] *= ruralChange));
|
||||
}
|
||||
if (!isFinite(ruralChange) && +ruralPop.value > 0) {
|
||||
const points = ruralPop.value / populationRate;
|
||||
const cells = pack.cells.i.filter(i => pack.cells.religion[i] === religionId);
|
||||
const pop = rn(points / cells.length);
|
||||
cells.forEach(i => (pack.cells.pop[i] = pop));
|
||||
}
|
||||
|
||||
const urbanChange = urbanPop.value / urban;
|
||||
if (isFinite(urbanChange) && urbanChange !== 1) {
|
||||
burgs.forEach(b => (b.population = rn(b.population * urbanChange, 4)));
|
||||
}
|
||||
if (!isFinite(urbanChange) && +urbanPop.value > 0) {
|
||||
const points = urbanPop.value / populationRate / urbanization;
|
||||
const population = rn(points / burgs.length, 4);
|
||||
burgs.forEach(b => (b.population = population));
|
||||
}
|
||||
|
||||
refreshReligionsEditor();
|
||||
}
|
||||
}
|
||||
|
||||
function religionRemovePrompt() {
|
||||
if (customization) return;
|
||||
|
||||
const religionId = +this.parentNode.dataset.id;
|
||||
confirmationDialog({
|
||||
title: "Remove religion",
|
||||
message: "Are you sure you want to remove the religion? <br>This action cannot be reverted",
|
||||
confirm: "Remove",
|
||||
onConfirm: () => removeReligion(religionId)
|
||||
});
|
||||
}
|
||||
|
||||
function removeReligion(religionId) {
|
||||
relig.select("#religion" + religionId).remove();
|
||||
relig.select("#religion-gap" + religionId).remove();
|
||||
debug.select("#religionsCenter" + religionId).remove();
|
||||
|
||||
pack.cells.religion.forEach((r, i) => {
|
||||
if (r === religionId) pack.cells.religion[i] = 0;
|
||||
});
|
||||
pack.religions[religionId].removed = true;
|
||||
|
||||
pack.religions
|
||||
.filter(r => r.i && !r.removed)
|
||||
.forEach(r => {
|
||||
r.origins = r.origins.filter(origin => origin !== religionId);
|
||||
if (!r.origins.length) r.origins = [0];
|
||||
});
|
||||
|
||||
refreshReligionsEditor();
|
||||
}
|
||||
|
||||
function drawReligionCenters() {
|
||||
debug.select("#religionCenters").remove();
|
||||
const religionCenters = debug
|
||||
.append("g")
|
||||
.attr("id", "religionCenters")
|
||||
.attr("stroke-width", 1.2)
|
||||
.attr("stroke", "#444444")
|
||||
.style("cursor", "move");
|
||||
|
||||
const data = pack.religions.filter(r => r.i && r.center && r.cells && !r.removed);
|
||||
religionCenters
|
||||
.selectAll("circle")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("id", d => "religionsCenter" + d.i)
|
||||
.attr("data-id", d => d.i)
|
||||
.attr("r", 4)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("cx", d => pack.cells.p[d.center][0])
|
||||
.attr("cy", d => pack.cells.p[d.center][1])
|
||||
.on("mouseenter", d => {
|
||||
tip(d.name + ". Drag to move the religion center", true);
|
||||
religionHighlightOn(event);
|
||||
})
|
||||
.on("mouseleave", d => {
|
||||
tip("", true);
|
||||
religionHighlightOff(event);
|
||||
})
|
||||
.call(d3.drag().on("start", religionCenterDrag));
|
||||
}
|
||||
|
||||
function religionCenterDrag() {
|
||||
const $el = d3.select(this);
|
||||
const religionId = +this.dataset.id;
|
||||
d3.event.on("drag", () => {
|
||||
const {x, y} = d3.event;
|
||||
$el.attr("cx", x).attr("cy", y);
|
||||
const cell = findCell(x, y);
|
||||
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
|
||||
pack.religions[religionId].center = cell;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleLegend() {
|
||||
if (legend.selectAll("*").size()) return clearLegend(); // hide legend
|
||||
|
||||
const data = pack.religions
|
||||
.filter(r => r.i && !r.removed && r.area)
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.map(r => [r.i, r.color, r.name]);
|
||||
drawLegend("Religions", data);
|
||||
}
|
||||
|
||||
function togglePercentageMode() {
|
||||
if ($body.dataset.type === "absolute") {
|
||||
$body.dataset.type = "percentage";
|
||||
const totalArea = +byId("religionsFooterArea").dataset.area;
|
||||
const totalPopulation = +byId("religionsFooterPopulation").dataset.population;
|
||||
|
||||
$body.querySelectorAll(":scope > div").forEach($el => {
|
||||
const {area, population} = $el.dataset;
|
||||
$el.querySelector(".religionArea").innerText = rn((+area / totalArea) * 100) + "%";
|
||||
$el.querySelector(".religionPopulation").innerText = rn((+population / totalPopulation) * 100) + "%";
|
||||
});
|
||||
} else {
|
||||
$body.dataset.type = "absolute";
|
||||
religionsEditorAddLines();
|
||||
}
|
||||
}
|
||||
|
||||
async function showHierarchy() {
|
||||
if (customization) return;
|
||||
const HeirarchyTree = await import("../hierarchy-tree.js?v=1.87.01");
|
||||
|
||||
const getDescription = religion => {
|
||||
const {name, type, form, rural, urban} = religion;
|
||||
|
||||
const getTypeText = () => {
|
||||
if (name.includes(type)) return "";
|
||||
if (form.includes(type)) return "";
|
||||
if (type === "Folk" || type === "Organized") return `. ${type} religion`;
|
||||
return `. ${type}`;
|
||||
};
|
||||
|
||||
const formText = form === type ? "" : ". " + form;
|
||||
const population = rural * populationRate + urban * populationRate * urbanization;
|
||||
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
|
||||
|
||||
return `${name}${getTypeText()}${formText}. ${populationText}`;
|
||||
};
|
||||
|
||||
const getShape = ({type}) => {
|
||||
if (type === "Folk") return "circle";
|
||||
if (type === "Organized") return "square";
|
||||
if (type === "Cult") return "hexagon";
|
||||
if (type === "Heresy") return "diamond";
|
||||
};
|
||||
|
||||
HeirarchyTree.open({
|
||||
type: "religions",
|
||||
data: pack.religions,
|
||||
onNodeEnter: religionHighlightOn,
|
||||
onNodeLeave: religionHighlightOff,
|
||||
getDescription,
|
||||
getShape
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExtinct() {
|
||||
$body.dataset.extinct = $body.dataset.extinct !== "show" ? "show" : "hide";
|
||||
religionsEditorAddLines();
|
||||
}
|
||||
|
||||
function enterReligionsManualAssignent() {
|
||||
if (!layerIsOn("toggleReligions")) toggleReligions();
|
||||
customization = 7;
|
||||
relig.append("g").attr("id", "temp");
|
||||
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "none"));
|
||||
byId("religionsManuallyButtons").style.display = "inline-block";
|
||||
debug.select("#religionCenters").style("display", "none");
|
||||
|
||||
religionsEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
|
||||
religionsFooter.style.display = "none";
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
|
||||
$("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
|
||||
|
||||
tip("Click on religion to select, drag the circle to change religion", true);
|
||||
viewbox
|
||||
.style("cursor", "crosshair")
|
||||
.on("click", selectReligionOnMapClick)
|
||||
.call(d3.drag().on("start", dragReligionBrush))
|
||||
.on("touchmove mousemove", moveReligionBrush);
|
||||
|
||||
$body.querySelector("div").classList.add("selected");
|
||||
}
|
||||
|
||||
function selectReligionOnLineClick(i) {
|
||||
if (customization !== 7) return;
|
||||
$body.querySelector("div.selected").classList.remove("selected");
|
||||
this.classList.add("selected");
|
||||
}
|
||||
|
||||
function selectReligionOnMapClick() {
|
||||
const point = d3.mouse(this);
|
||||
const i = findCell(point[0], point[1]);
|
||||
if (pack.cells.h[i] < 20) return;
|
||||
|
||||
const assigned = relig.select("#temp").select("polygon[data-cell='" + i + "']");
|
||||
const religion = assigned.size() ? +assigned.attr("data-religion") : pack.cells.religion[i];
|
||||
|
||||
$body.querySelector("div.selected").classList.remove("selected");
|
||||
$body.querySelector("div[data-id='" + religion + "']").classList.add("selected");
|
||||
}
|
||||
|
||||
function dragReligionBrush() {
|
||||
const radius = +byId("religionsManuallyBrushNumber").value;
|
||||
|
||||
d3.event.on("drag", () => {
|
||||
if (!d3.event.dx && !d3.event.dy) return;
|
||||
const [x, y] = d3.mouse(this);
|
||||
moveCircle(x, y, radius);
|
||||
|
||||
const found = radius > 5 ? findAll(x, y, radius) : [findCell(x, y, radius)];
|
||||
const selection = found.filter(isLand);
|
||||
if (selection) changeReligionForSelection(selection);
|
||||
});
|
||||
}
|
||||
|
||||
// change religion within selection
|
||||
function changeReligionForSelection(selection) {
|
||||
const temp = relig.select("#temp");
|
||||
const selected = $body.querySelector("div.selected");
|
||||
const religionNew = +selected.dataset.id;
|
||||
const color = pack.religions[religionNew].color || "#ffffff";
|
||||
|
||||
selection.forEach(function (i) {
|
||||
const exists = temp.select("polygon[data-cell='" + i + "']");
|
||||
const religionOld = exists.size() ? +exists.attr("data-religion") : pack.cells.religion[i];
|
||||
if (religionNew === religionOld) return;
|
||||
|
||||
// change of append new element
|
||||
if (exists.size()) exists.attr("data-religion", religionNew).attr("fill", color);
|
||||
else
|
||||
temp
|
||||
.append("polygon")
|
||||
.attr("data-cell", i)
|
||||
.attr("data-religion", religionNew)
|
||||
.attr("points", getPackPolygon(i))
|
||||
.attr("fill", color);
|
||||
});
|
||||
}
|
||||
|
||||
function moveReligionBrush() {
|
||||
showMainTip();
|
||||
const [x, y] = d3.mouse(this);
|
||||
const radius = +byId("religionsManuallyBrushNumber").value;
|
||||
moveCircle(x, y, radius);
|
||||
}
|
||||
|
||||
function applyReligionsManualAssignent() {
|
||||
const changed = relig.select("#temp").selectAll("polygon");
|
||||
changed.each(function () {
|
||||
const i = +this.dataset.cell;
|
||||
const r = +this.dataset.religion;
|
||||
pack.cells.religion[i] = r;
|
||||
});
|
||||
|
||||
if (changed.size()) {
|
||||
drawReligions();
|
||||
refreshReligionsEditor();
|
||||
drawReligionCenters();
|
||||
}
|
||||
exitReligionsManualAssignment();
|
||||
}
|
||||
|
||||
function exitReligionsManualAssignment(close) {
|
||||
customization = 0;
|
||||
relig.select("#temp").remove();
|
||||
removeCircle();
|
||||
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "inline-block"));
|
||||
byId("religionsManuallyButtons").style.display = "none";
|
||||
|
||||
byId("religionsEditor")
|
||||
.querySelectorAll(".hide")
|
||||
.forEach(el => el.classList.remove("hidden"));
|
||||
byId("religionsFooter").style.display = "block";
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
|
||||
if (!close) $("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
|
||||
|
||||
debug.select("#religionCenters").style("display", null);
|
||||
restoreDefaultEvents();
|
||||
clearMainTip();
|
||||
const $selected = $body.querySelector("div.selected");
|
||||
if ($selected) $selected.classList.remove("selected");
|
||||
}
|
||||
|
||||
function enterAddReligionMode() {
|
||||
if (this.classList.contains("pressed")) return exitAddReligionMode();
|
||||
|
||||
customization = 8;
|
||||
this.classList.add("pressed");
|
||||
tip("Click on the map to add a new religion", true);
|
||||
viewbox.style("cursor", "crosshair").on("click", addReligion);
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
|
||||
}
|
||||
|
||||
function exitAddReligionMode() {
|
||||
customization = 0;
|
||||
restoreDefaultEvents();
|
||||
clearMainTip();
|
||||
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
|
||||
if (religionsAdd.classList.contains("pressed")) religionsAdd.classList.remove("pressed");
|
||||
}
|
||||
|
||||
function addReligion() {
|
||||
const [x, y] = d3.mouse(this);
|
||||
const center = findCell(x, y);
|
||||
if (pack.cells.h[center] < 20)
|
||||
return tip("You cannot place religion center into the water. Please click on a land cell", false, "error");
|
||||
|
||||
const occupied = pack.religions.some(r => !r.removed && r.center === center);
|
||||
if (occupied) return tip("This cell is already a religion center. Please select a different cell", false, "error");
|
||||
|
||||
if (d3.event.shiftKey === false) exitAddReligionMode();
|
||||
Religions.add(center);
|
||||
|
||||
drawReligions();
|
||||
refreshReligionsEditor();
|
||||
drawReligionCenters();
|
||||
}
|
||||
|
||||
function downloadReligionsCsv() {
|
||||
const unit = getAreaUnit("2");
|
||||
const headers = `Id,Name,Color,Type,Form,Supreme Deity,Area ${unit},Believers,Origins`;
|
||||
const lines = Array.from($body.querySelectorAll(":scope > div"));
|
||||
const data = lines.map($line => {
|
||||
const {id, name, color, type, form, deity, area, population} = $line.dataset;
|
||||
const deityText = '"' + deity + '"';
|
||||
const {origins} = pack.religions[+id];
|
||||
const originList = (origins || []).filter(origin => origin).map(origin => pack.religions[origin].name);
|
||||
const originText = '"' + originList.join(", ") + '"';
|
||||
return [id, name, color, type, form, deityText, area, population, originText].join(",");
|
||||
});
|
||||
const csvData = [headers].concat(data).join("\n");
|
||||
|
||||
const name = getFileName("Religions") + ".csv";
|
||||
downloadFile(csvData, name);
|
||||
}
|
||||
|
||||
function closeReligionsEditor() {
|
||||
debug.select("#religionCenters").remove();
|
||||
exitReligionsManualAssignment("close");
|
||||
exitAddReligionMode();
|
||||
}
|
||||
1374
src/modules/dynamic/editors/states-editor.js
Normal file
1374
src/modules/dynamic/editors/states-editor.js
Normal file
File diff suppressed because it is too large
Load diff
227
src/modules/dynamic/export-json.js
Normal file
227
src/modules/dynamic/export-json.js
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import {tip} from "/src/scripts/tooltips";
|
||||
|
||||
export function exportToJson(type) {
|
||||
if (customization)
|
||||
return tip("Data cannot be exported when edit mode is active, please exit the mode and retry", false, "error");
|
||||
closeDialogs("#alert");
|
||||
|
||||
const typeMap = {
|
||||
Full: getFullDataJson,
|
||||
Minimal: getMinimalDataJson,
|
||||
PackCells: getPackCellsDataJson,
|
||||
GridCells: getGridCellsDataJson
|
||||
};
|
||||
|
||||
const mapData = typeMap[type]();
|
||||
const blob = new Blob([mapData], {type: "application/json"});
|
||||
const URL = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.download = getFileName(type) + ".json";
|
||||
link.href = URL;
|
||||
link.click();
|
||||
tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
|
||||
window.URL.revokeObjectURL(URL);
|
||||
}
|
||||
|
||||
function getFullDataJson() {
|
||||
TIME && console.time("getFullDataJson");
|
||||
|
||||
const info = getMapInfo();
|
||||
const settings = getSettings();
|
||||
const cells = getPackCellsData();
|
||||
const vertices = getPackVerticesData();
|
||||
const exportData = {info, settings, coords: mapCoordinates, cells, vertices, biomes: biomesData, notes, nameBases};
|
||||
|
||||
TIME && console.timeEnd("getFullDataJson");
|
||||
return JSON.stringify(exportData);
|
||||
}
|
||||
|
||||
function getMinimalDataJson() {
|
||||
TIME && console.time("getMinimalDataJson");
|
||||
|
||||
const info = getMapInfo();
|
||||
const settings = getSettings();
|
||||
const packData = {
|
||||
features: pack.features,
|
||||
cultures: pack.cultures,
|
||||
burgs: pack.burgs,
|
||||
states: pack.states,
|
||||
provinces: pack.provinces,
|
||||
religions: pack.religions,
|
||||
rivers: pack.rivers,
|
||||
markers: pack.markers
|
||||
};
|
||||
const exportData = {info, settings, coords: mapCoordinates, pack: packData, biomes: biomesData, notes, nameBases};
|
||||
|
||||
TIME && console.timeEnd("getMinimalDataJson");
|
||||
return JSON.stringify(exportData);
|
||||
}
|
||||
|
||||
function getPackCellsDataJson() {
|
||||
TIME && console.time("getCellsDataJson");
|
||||
|
||||
const info = getMapInfo();
|
||||
const cells = getPackCellsData();
|
||||
const exportData = {info, cells};
|
||||
|
||||
TIME && console.timeEnd("getCellsDataJson");
|
||||
return JSON.stringify(exportData);
|
||||
}
|
||||
|
||||
function getGridCellsDataJson() {
|
||||
TIME && console.time("getGridCellsDataJson");
|
||||
|
||||
const info = getMapInfo();
|
||||
const gridCells = getGridCellsData();
|
||||
const exportData = {info, gridCells};
|
||||
|
||||
TIME && console.log("getGridCellsDataJson");
|
||||
return JSON.stringify(exportData);
|
||||
}
|
||||
|
||||
function getMapInfo() {
|
||||
const info = {
|
||||
version,
|
||||
description: "Azgaar's Fantasy Map Generator output: azgaar.github.io/Fantasy-map-generator",
|
||||
exportedAt: new Date().toISOString(),
|
||||
mapName: mapName.value,
|
||||
seed,
|
||||
mapId
|
||||
};
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
const settings = {
|
||||
distanceUnit: distanceUnitInput.value,
|
||||
distanceScale: distanceScaleInput.value,
|
||||
areaUnit: areaUnit.value,
|
||||
heightUnit: heightUnit.value,
|
||||
heightExponent: heightExponentInput.value,
|
||||
temperatureScale: temperatureScale.value,
|
||||
barSize: barSizeInput.value,
|
||||
barLabel: barLabel.value,
|
||||
barBackOpacity: barBackOpacity.value,
|
||||
barBackColor: barBackColor.value,
|
||||
barPosX: barPosX.value,
|
||||
barPosY: barPosY.value,
|
||||
populationRate: populationRate,
|
||||
urbanization: urbanization,
|
||||
mapSize: mapSizeOutput.value,
|
||||
latitudeO: latitudeOutput.value,
|
||||
temperatureEquator: temperatureEquatorOutput.value,
|
||||
temperaturePole: temperaturePoleOutput.value,
|
||||
prec: precOutput.value,
|
||||
options: options,
|
||||
mapName: mapName.value,
|
||||
hideLabels: hideLabels.checked,
|
||||
stylePreset: stylePreset.value,
|
||||
rescaleLabels: rescaleLabels.checked,
|
||||
urbanDensity: urbanDensity
|
||||
};
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
function getPackCellsData() {
|
||||
const cellConverted = {
|
||||
i: Array.from(pack.cells.i),
|
||||
v: pack.cells.v,
|
||||
c: pack.cells.c,
|
||||
p: pack.cells.p,
|
||||
g: Array.from(pack.cells.g),
|
||||
h: Array.from(pack.cells.h),
|
||||
area: Array.from(pack.cells.area),
|
||||
f: Array.from(pack.cells.f),
|
||||
t: Array.from(pack.cells.t),
|
||||
haven: Array.from(pack.cells.haven),
|
||||
harbor: Array.from(pack.cells.harbor),
|
||||
fl: Array.from(pack.cells.fl),
|
||||
r: Array.from(pack.cells.r),
|
||||
conf: Array.from(pack.cells.conf),
|
||||
biome: Array.from(pack.cells.biome),
|
||||
s: Array.from(pack.cells.s),
|
||||
pop: Array.from(pack.cells.pop),
|
||||
culture: Array.from(pack.cells.culture),
|
||||
burg: Array.from(pack.cells.burg),
|
||||
road: Array.from(pack.cells.road),
|
||||
crossroad: Array.from(pack.cells.crossroad),
|
||||
state: Array.from(pack.cells.state),
|
||||
religion: Array.from(pack.cells.religion),
|
||||
province: Array.from(pack.cells.province)
|
||||
};
|
||||
|
||||
const cellObjArr = [];
|
||||
{
|
||||
cellConverted.i.forEach(value => {
|
||||
const cellobj = {
|
||||
i: value,
|
||||
v: cellConverted.v[value],
|
||||
c: cellConverted.c[value],
|
||||
p: cellConverted.p[value],
|
||||
g: cellConverted.g[value],
|
||||
h: cellConverted.h[value],
|
||||
area: cellConverted.area[value],
|
||||
f: cellConverted.f[value],
|
||||
t: cellConverted.t[value],
|
||||
haven: cellConverted.haven[value],
|
||||
harbor: cellConverted.harbor[value],
|
||||
fl: cellConverted.fl[value],
|
||||
r: cellConverted.r[value],
|
||||
conf: cellConverted.conf[value],
|
||||
biome: cellConverted.biome[value],
|
||||
s: cellConverted.s[value],
|
||||
pop: cellConverted.pop[value],
|
||||
culture: cellConverted.culture[value],
|
||||
burg: cellConverted.burg[value],
|
||||
road: cellConverted.road[value],
|
||||
crossroad: cellConverted.crossroad[value],
|
||||
state: cellConverted.state[value],
|
||||
religion: cellConverted.religion[value],
|
||||
province: cellConverted.province[value]
|
||||
};
|
||||
cellObjArr.push(cellobj);
|
||||
});
|
||||
}
|
||||
|
||||
const cellsData = {
|
||||
cells: cellObjArr,
|
||||
features: pack.features,
|
||||
cultures: pack.cultures,
|
||||
burgs: pack.burgs,
|
||||
states: pack.states,
|
||||
provinces: pack.provinces,
|
||||
religions: pack.religions,
|
||||
rivers: pack.rivers,
|
||||
markers: pack.markers
|
||||
};
|
||||
|
||||
return cellsData;
|
||||
}
|
||||
|
||||
function getGridCellsData() {
|
||||
const gridData = {
|
||||
cellsDesired: grid.cellsDesired,
|
||||
spacing: grid.spacing,
|
||||
cellsY: grid.cellsY,
|
||||
cellsX: grid.cellsX,
|
||||
points: grid.points,
|
||||
boundary: grid.boundary
|
||||
};
|
||||
return gridData;
|
||||
}
|
||||
|
||||
function getPackVerticesData() {
|
||||
const {vertices} = pack;
|
||||
const verticesNumber = vertices.p.length;
|
||||
const verticesArray = new Array(verticesNumber);
|
||||
for (let i = 0; i < verticesNumber; i++) {
|
||||
verticesArray[i] = {
|
||||
p: vertices.p[i],
|
||||
v: vertices.v[i],
|
||||
c: vertices.c[i]
|
||||
};
|
||||
}
|
||||
return verticesArray;
|
||||
}
|
||||
345
src/modules/dynamic/heightmap-selection.js
Normal file
345
src/modules/dynamic/heightmap-selection.js
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import {shouldRegenerateGrid, generateGrid} from "/src/utils/graphUtils";
|
||||
import {byId} from "/src/utils/shorthands";
|
||||
import {generateSeed} from "/src/utils/probabilityUtils";
|
||||
|
||||
const initialSeed = generateSeed();
|
||||
let graph = getGraph(grid);
|
||||
|
||||
appendStyleSheet();
|
||||
insertHtml();
|
||||
addListeners();
|
||||
|
||||
export function open() {
|
||||
closeDialogs(".stable");
|
||||
|
||||
const $templateInput = byId("templateInput");
|
||||
setSelected($templateInput.value);
|
||||
graph = getGraph(graph);
|
||||
|
||||
$("#heightmapSelection").dialog({
|
||||
title: "Select Heightmap",
|
||||
resizable: false,
|
||||
position: {my: "center", at: "center", of: "svg"},
|
||||
buttons: {
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
},
|
||||
Select: function () {
|
||||
const id = getSelected();
|
||||
applyOption($templateInput, id, getName(id));
|
||||
lock("template");
|
||||
|
||||
$(this).dialog("close");
|
||||
},
|
||||
"New Map": function () {
|
||||
const id = getSelected();
|
||||
applyOption($templateInput, id, getName(id));
|
||||
lock("template");
|
||||
|
||||
const seed = getSeed();
|
||||
regeneratePrompt({seed, graph});
|
||||
|
||||
$(this).dialog("close");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendStyleSheet() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = /* css */ `
|
||||
div.dialog > div.heightmap-selection {
|
||||
width: 70vw;
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.heightmap-selection_container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.heightmap-selection_container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
grid-gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 2000px) {
|
||||
.heightmap-selection_container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.heightmap-selection_options {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
.heightmap-selection_options > div:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.heightmap-selection_options {
|
||||
grid-template-columns: 3fr 1fr;
|
||||
}
|
||||
|
||||
.heightmap-selection_options > div:first-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.heightmap-selection_options > div:last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.heightmap-selection article {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.1s ease-in-out;
|
||||
filter: drop-shadow(1px 1px 4px #999);
|
||||
}
|
||||
|
||||
.heightmap-selection article:hover {
|
||||
background-color: #ddd;
|
||||
filter: drop-shadow(1px 1px 8px #999);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heightmap-selection article.selected {
|
||||
background-color: #ccc;
|
||||
outline: 1px solid var(--dark-solid);
|
||||
filter: drop-shadow(1px 1px 8px #999);
|
||||
}
|
||||
|
||||
.heightmap-selection article > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 1px;
|
||||
}
|
||||
|
||||
.heightmap-selection article > img {
|
||||
width: 100%;
|
||||
aspect-ratio: ${graphWidth}/${graphHeight};
|
||||
border-radius: 8px;
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.heightmap-selection article .regeneratePreview {
|
||||
outline: 1px solid #bbb;
|
||||
padding: 1px 3px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.heightmap-selection article .regeneratePreview:hover {
|
||||
outline: 1px solid #666;
|
||||
}
|
||||
|
||||
.heightmap-selection article .regeneratePreview:active {
|
||||
outline: 1px solid #333;
|
||||
color: #000;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function insertHtml() {
|
||||
const heightmapSelectionHtml = /* html */ `<div id="heightmapSelection" class="dialog stable">
|
||||
<div class="heightmap-selection">
|
||||
<section data-tip="Select heightmap template – template provides unique, but similar-looking maps on generation">
|
||||
<header><h1>Heightmap templates</h1></header>
|
||||
<div class="heightmap-selection_container"></div>
|
||||
</section>
|
||||
<section data-tip="Select precreated heightmap – it will be the same for each map">
|
||||
<header><h1>Precreated heightmaps</h1></header>
|
||||
<div class="heightmap-selection_container"></div>
|
||||
</section>
|
||||
<section>
|
||||
<header><h1>Options</h1></header>
|
||||
<div class="heightmap-selection_options">
|
||||
<div>
|
||||
<label data-tip="Rerender all preview images" class="checkbox-label" id="heightmapSelectionRedrawPreview">
|
||||
<i class="icon-cw"></i>
|
||||
Redraw preview
|
||||
</label>
|
||||
<div>
|
||||
<input id="heightmapSelectionRenderOcean" class="checkbox" type="checkbox" />
|
||||
<label data-tip="Draw heights of water cells" for="heightmapSelectionRenderOcean" class="checkbox-label">Render ocean heights</label>
|
||||
</div>
|
||||
<div data-tip="Color scheme used for heightmap preview">
|
||||
Color scheme
|
||||
<select id="heightmapSelectionColorScheme">
|
||||
<option value="bright" selected>Bright</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="monochrome">Monochrome</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button data-tip="Open Template Editor" data-tool="templateEditor" id="heightmapSelectionEditTemplates">Edit Templates</button>
|
||||
<button data-tip="Open Image Converter" data-tool="imageConverter" id="heightmapSelectionImportHeightmap">Import Heightmap</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
byId("dialogs").insertAdjacentHTML("beforeend", heightmapSelectionHtml);
|
||||
|
||||
const sections = document.getElementsByClassName("heightmap-selection_container");
|
||||
|
||||
sections[0].innerHTML = Object.keys(heightmapTemplates)
|
||||
.map(key => {
|
||||
const name = heightmapTemplates[key].name;
|
||||
Math.random = aleaPRNG(initialSeed);
|
||||
const heights = HeightmapGenerator.fromTemplate(graph, key);
|
||||
const dataUrl = drawHeights(heights);
|
||||
|
||||
return /* html */ `<article data-id="${key}" data-seed="${initialSeed}">
|
||||
<img src="${dataUrl}" alt="${name}" />
|
||||
<div>
|
||||
${name}
|
||||
<span data-tip="Regenerate preview" class="icon-cw regeneratePreview"></span>
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
sections[1].innerHTML = Object.keys(precreatedHeightmaps)
|
||||
.map(key => {
|
||||
const name = precreatedHeightmaps[key].name;
|
||||
drawPrecreatedHeightmap(key);
|
||||
|
||||
return /* html */ `<article data-id="${key}" data-seed="${initialSeed}">
|
||||
<img alt="${name}" />
|
||||
<div>${name}</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function addListeners() {
|
||||
byId("heightmapSelection").on("click", event => {
|
||||
const article = event.target.closest("#heightmapSelection article");
|
||||
if (!article) return;
|
||||
|
||||
const id = article.dataset.id;
|
||||
if (event.target.matches("span.icon-cw")) regeneratePreview(article, id);
|
||||
setSelected(id);
|
||||
});
|
||||
|
||||
byId("heightmapSelectionRenderOcean").on("change", redrawAll);
|
||||
byId("heightmapSelectionColorScheme").on("change", redrawAll);
|
||||
byId("heightmapSelectionRedrawPreview").on("click", redrawAll);
|
||||
byId("heightmapSelectionEditTemplates").on("click", confirmHeightmapEdit);
|
||||
byId("heightmapSelectionImportHeightmap").on("click", confirmHeightmapEdit);
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
return byId("heightmapSelection").querySelector(".selected")?.dataset?.id;
|
||||
}
|
||||
|
||||
function setSelected(id) {
|
||||
const $heightmapSelection = byId("heightmapSelection");
|
||||
$heightmapSelection.querySelector(".selected")?.classList?.remove("selected");
|
||||
$heightmapSelection.querySelector(`[data-id="${id}"]`)?.classList?.add("selected");
|
||||
}
|
||||
|
||||
function getSeed() {
|
||||
return byId("heightmapSelection").querySelector(".selected")?.dataset?.seed;
|
||||
}
|
||||
|
||||
function getName(id) {
|
||||
const isTemplate = id in heightmapTemplates;
|
||||
return isTemplate ? heightmapTemplates[id].name : precreatedHeightmaps[id].name;
|
||||
}
|
||||
|
||||
function getGraph(currentGraph) {
|
||||
const newGraph = shouldRegenerateGrid(currentGraph) ? generateGrid() : structuredClone(currentGraph);
|
||||
delete newGraph.cells.h;
|
||||
return newGraph;
|
||||
}
|
||||
|
||||
function drawHeights(heights) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = graph.cellsX;
|
||||
canvas.height = graph.cellsY;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.createImageData(graph.cellsX, graph.cellsY);
|
||||
|
||||
const schemeId = byId("heightmapSelectionColorScheme").value;
|
||||
const scheme = getColorScheme(schemeId);
|
||||
const renderOcean = byId("heightmapSelectionRenderOcean").checked;
|
||||
const getHeight = height => (height < 20 ? (renderOcean ? height : 0) : height);
|
||||
|
||||
for (let i = 0; i < heights.length; i++) {
|
||||
const color = scheme(1 - getHeight(heights[i]) / 100);
|
||||
const {r, g, b} = d3.color(color);
|
||||
|
||||
const n = i * 4;
|
||||
imageData.data[n] = r;
|
||||
imageData.data[n + 1] = g;
|
||||
imageData.data[n + 2] = b;
|
||||
imageData.data[n + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawTemplatePreview(id) {
|
||||
const heights = HeightmapGenerator.fromTemplate(graph, id);
|
||||
const dataUrl = drawHeights(heights);
|
||||
const article = byId("heightmapSelection").querySelector(`[data-id="${id}"]`);
|
||||
article.querySelector("img").src = dataUrl;
|
||||
}
|
||||
|
||||
async function drawPrecreatedHeightmap(id) {
|
||||
const heights = await HeightmapGenerator.fromPrecreated(graph, id);
|
||||
const dataUrl = drawHeights(heights);
|
||||
const article = byId("heightmapSelection").querySelector(`[data-id="${id}"]`);
|
||||
article.querySelector("img").src = dataUrl;
|
||||
}
|
||||
|
||||
function regeneratePreview(article, id) {
|
||||
graph = getGraph(graph);
|
||||
const seed = generateSeed();
|
||||
article.dataset.seed = seed;
|
||||
Math.random = aleaPRNG(seed);
|
||||
drawTemplatePreview(id);
|
||||
}
|
||||
|
||||
function redrawAll() {
|
||||
graph = getGraph(graph);
|
||||
const articles = byId("heightmapSelection").querySelectorAll(`article`);
|
||||
for (const article of articles) {
|
||||
const {id, seed} = article.dataset;
|
||||
Math.random = aleaPRNG(seed);
|
||||
|
||||
const isTemplate = id in heightmapTemplates;
|
||||
if (isTemplate) drawTemplatePreview(id);
|
||||
else drawPrecreatedHeightmap(id);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmHeightmapEdit() {
|
||||
const tool = this.dataset.tool;
|
||||
|
||||
confirmationDialog({
|
||||
title: this.dataset.tip,
|
||||
message: "Opening the tool will erase the current map. Are you sure you want to proceed?",
|
||||
confirm: "Continue",
|
||||
onConfirm: () => editHeightmap({mode: "erase", tool})
|
||||
});
|
||||
}
|
||||
512
src/modules/dynamic/hierarchy-tree.js
Normal file
512
src/modules/dynamic/hierarchy-tree.js
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import {byId} from "/src/utils/shorthands";
|
||||
import {tip} from "/src/scripts/tooltips";
|
||||
import {capitalize} from "/src/utils/stringUtils";
|
||||
|
||||
appendStyleSheet();
|
||||
insertHtml();
|
||||
|
||||
const MARGINS = {top: 10, right: 10, bottom: -5, left: 10};
|
||||
|
||||
const handleZoom = () => viewbox.attr("transform", d3.event.transform);
|
||||
const zoom = d3.zoom().scaleExtent([0.2, 1.5]).on("zoom", handleZoom);
|
||||
|
||||
// store old root for transitions
|
||||
let oldRoot;
|
||||
|
||||
// define svg elements
|
||||
const svg = d3.select("#hierarchyTree > svg").call(zoom);
|
||||
const viewbox = svg.select("g#hierarchyTree_viewbox");
|
||||
const primaryLinks = viewbox.select("g#hierarchyTree_linksPrimary");
|
||||
const secondaryLinks = viewbox.select("g#hierarchyTree_linksSecondary");
|
||||
const nodes = viewbox.select("g#hierarchyTree_nodes");
|
||||
const dragLine = viewbox.select("path#hierarchyTree_dragLine");
|
||||
|
||||
// properties
|
||||
let dataElements; // {i, name, type, origins}[], e.g. path.religions
|
||||
let validElements; // not-removed dataElements
|
||||
let onNodeEnter; // d3Data => void
|
||||
let onNodeLeave; // d3Data => void
|
||||
let getDescription; // dataElement => string
|
||||
let getShape; // dataElement => string;
|
||||
|
||||
export function open(props) {
|
||||
closeDialogs("#hierarchyTree, .stable");
|
||||
|
||||
dataElements = props.data;
|
||||
dataElements[0].origins = [null];
|
||||
validElements = dataElements.filter(r => !r.removed);
|
||||
if (validElements.length < 3) return tip(`Not enough ${props.type} to show hierarchy`, false, "error");
|
||||
|
||||
onNodeEnter = props.onNodeEnter;
|
||||
onNodeLeave = props.onNodeLeave;
|
||||
getDescription = props.getDescription;
|
||||
getShape = props.getShape;
|
||||
|
||||
const root = getRoot();
|
||||
const treeWidth = root.leaves().length * 50;
|
||||
const treeHeight = root.height * 50;
|
||||
|
||||
const w = treeWidth - MARGINS.left - MARGINS.right;
|
||||
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
|
||||
const treeLayout = d3.tree().size([w, h]);
|
||||
|
||||
const width = minmax(treeWidth, 300, innerWidth * 0.75);
|
||||
const height = minmax(treeHeight, 200, innerHeight * 0.75);
|
||||
|
||||
zoom.extent([Array(2).fill(0), [width, height]]);
|
||||
svg.attr("viewBox", `0, 0, ${width}, ${height}`);
|
||||
|
||||
$("#hierarchyTree").dialog({
|
||||
title: `${capitalize(props.type)} tree`,
|
||||
position: {my: "left center", at: "left+10 center", of: "svg"},
|
||||
width
|
||||
});
|
||||
|
||||
renderTree(root, treeLayout);
|
||||
}
|
||||
|
||||
function appendStyleSheet() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = /* css */ `
|
||||
#hierarchyTree_selectedOrigins > button {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
#hierarchyTree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#hierarchyTree > svg {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hierarchyTree_selectedOrigins {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.hierarchyTree_selectedOrigin {
|
||||
border: 1px solid #aaa;
|
||||
background: none;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.hierarchyTree_selectedOrigin:hover {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.hierarchyTree_selectedOrigin::after {
|
||||
content: "✕";
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.hierarchyTree_selectedOrigin:hover:after {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#hierarchyTree_originSelector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#hierarchyTree_originSelector > form > div {
|
||||
padding: 0.3em;
|
||||
margin: 1px 0;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
#hierarchyTree_originSelector > form > div:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
#hierarchyTree_originSelector > form > div[checked] {
|
||||
background-color: #c6d6d6;
|
||||
}
|
||||
|
||||
#hierarchyTree_nodes > g > text {
|
||||
pointer-events: none;
|
||||
stroke: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#hierarchyTree_nodes > g.selected {
|
||||
stroke: #c13119;
|
||||
stroke-width: 1;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
#hierarchyTree_dragLine {
|
||||
marker-end: url(#end-arrow);
|
||||
stroke: #333333;
|
||||
stroke-dasharray: 5;
|
||||
stroke-dashoffset: 1000;
|
||||
animation: dash 80s linear backwards;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function insertHtml() {
|
||||
const html = /* html */ `<div id="hierarchyTree" class="dialog" style="overflow: hidden;">
|
||||
<svg>
|
||||
<g id="hierarchyTree_viewbox" style="text-anchor: middle; dominant-baseline: central">
|
||||
<g transform="translate(10, -45)">
|
||||
<g id="hierarchyTree_links" fill="none" stroke="#aaa">
|
||||
<g id="hierarchyTree_linksPrimary"></g>
|
||||
<g id="hierarchyTree_linksSecondary" stroke-dasharray="1"></g>
|
||||
</g>
|
||||
<g id="hierarchyTree_nodes"></g>
|
||||
<path id="hierarchyTree_dragLine" path='' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div id="hierarchyTree_details" class='chartInfo'>
|
||||
<div id='hierarchyTree_infoLine' style="display: block">‍</div>
|
||||
<div id='hierarchyTree_selected' style="display: none">
|
||||
<span><span id='hierarchyTree_selectedName'></span>. </span>
|
||||
<span data-name="Type short name (abbreviation)">Abbreviation: <input id='hierarchyTree_selectedCode' type='text' maxlength='3' size='3' /></span>
|
||||
<span>Origins: <span id='hierarchyTree_selectedOrigins'></span></span>
|
||||
<button data-tip='Edit this node's origins' class="hierarchyTree_selectedButton" id='hierarchyTree_selectedSelectButton'>Edit</button>
|
||||
<button data-tip='Unselect this node' class="hierarchyTree_selectedButton" id='hierarchyTree_selectedCloseButton'>Unselect</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hierarchyTree_originSelector"></div>
|
||||
</div>`;
|
||||
|
||||
byId("dialogs").insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
|
||||
function getRoot() {
|
||||
const root = d3
|
||||
.stratify()
|
||||
.id(d => d.i)
|
||||
.parentId(d => d.origins[0])(validElements);
|
||||
|
||||
oldRoot = root;
|
||||
return root;
|
||||
}
|
||||
|
||||
function getLinkKey(d) {
|
||||
return `${d.source.id}-${d.target.id}`;
|
||||
}
|
||||
|
||||
function getNodeKey(d) {
|
||||
return d.id;
|
||||
}
|
||||
|
||||
function getLinkPath(d) {
|
||||
const {
|
||||
source: {x: sx, y: sy},
|
||||
target: {x: tx, y: ty}
|
||||
} = d;
|
||||
return `M${sx},${sy} C${sx},${(sy * 3 + ty) / 4} ${tx},${(sy * 2 + ty) / 3} ${tx},${ty}`;
|
||||
}
|
||||
|
||||
function getSecondaryLinks(root) {
|
||||
const nodes = root.descendants();
|
||||
const links = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const origins = node.data.origins;
|
||||
|
||||
for (let i = 1; i < origins.length; i++) {
|
||||
const source = nodes.find(n => n.data.i === origins[i]);
|
||||
if (source) links.push({source, target: node});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
const shapesMap = {
|
||||
undefined: "M5,0A5,5,0,1,1,-5,0A5,5,0,1,1,5,0", // small circle
|
||||
circle: "M11.3,0A11.3,11.3,0,1,1,-11.3,0A11.3,11.3,0,1,1,11.3,0",
|
||||
square: "M-11,-11h22v22h-22Z",
|
||||
hexagon: "M-6.5,-11.26l13,0l6.5,11.26l-6.5,11.26l-13,0l-6.5,-11.26Z",
|
||||
diamond: "M0,-14L14,0L0,14L-14,0Z",
|
||||
concave: "M-11,-11l11,2l11,-2l-2,11l2,11l-11,-2l-11,2l2,-11Z",
|
||||
octagon: "M-4.97,-12.01 l9.95,0 l7.04,7.04 l0,9.95 l-7.04,7.04 l-9.95,0 l-7.04,-7.04 l0,-9.95Z",
|
||||
pentagon: "M0,-14l14,11l-6,14h-16l-6,-14Z"
|
||||
};
|
||||
|
||||
const getSortIndex = node => {
|
||||
const descendants = node.descendants();
|
||||
const secondaryOrigins = descendants.map(({data}) => data.origins.slice(1)).flat();
|
||||
|
||||
if (secondaryOrigins.length === 0) return node.data.i;
|
||||
return d3.mean(secondaryOrigins);
|
||||
};
|
||||
|
||||
function renderTree(root, treeLayout) {
|
||||
treeLayout(root.sort((a, b) => getSortIndex(a) - getSortIndex(b)));
|
||||
|
||||
primaryLinks.selectAll("path").data(root.links(), getLinkKey).join("path").attr("d", getLinkPath);
|
||||
secondaryLinks.selectAll("path").data(getSecondaryLinks(root), getLinkKey).join("path").attr("d", getLinkPath);
|
||||
|
||||
const node = nodes
|
||||
.selectAll("g")
|
||||
.data(root.descendants(), getNodeKey)
|
||||
.join("g")
|
||||
.attr("data-id", d => d.data.i)
|
||||
.attr("stroke", "#333")
|
||||
.attr("transform", d => `translate(${d.x}, ${d.y})`)
|
||||
.on("mouseenter", handleNoteEnter)
|
||||
.on("mouseleave", handleNodeExit)
|
||||
.on("click", selectElement)
|
||||
.call(d3.drag().on("start", dragToReorigin));
|
||||
|
||||
node
|
||||
.selectAll("path")
|
||||
.data(d => [d])
|
||||
.join("path")
|
||||
.attr("d", d => shapesMap[getShape(d.data)])
|
||||
.attr("fill", d => d.data.color || "#ffffff")
|
||||
.attr("stroke-dasharray", d => (d.data.cells ? "none" : "1"));
|
||||
|
||||
node
|
||||
.selectAll("text")
|
||||
.data(d => [d])
|
||||
.join("text")
|
||||
.text(d => d.data.code || "");
|
||||
}
|
||||
|
||||
function mapCoords(newRoot, prevRoot) {
|
||||
newRoot.x = prevRoot.x;
|
||||
newRoot.y = prevRoot.y;
|
||||
|
||||
for (const node of newRoot.descendants()) {
|
||||
const prevNode = prevRoot.descendants().find(n => n.data.i === node.data.i);
|
||||
if (prevNode) {
|
||||
node.x = prevNode.x;
|
||||
node.y = prevNode.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTree() {
|
||||
const prevRoot = oldRoot;
|
||||
const root = getRoot();
|
||||
mapCoords(root, prevRoot);
|
||||
|
||||
const linksUpdateDuration = 50;
|
||||
const moveDuration = 1000;
|
||||
|
||||
// old layout: update links at old nodes positions
|
||||
const linkEnter = enter =>
|
||||
enter
|
||||
.append("path")
|
||||
.attr("d", getLinkPath)
|
||||
.attr("opacity", 0)
|
||||
.call(enter => enter.transition().duration(linksUpdateDuration).attr("opacity", 1));
|
||||
|
||||
const linkUpdate = update =>
|
||||
update.call(update => update.transition().duration(linksUpdateDuration).attr("d", getLinkPath));
|
||||
|
||||
const linkExit = exit =>
|
||||
exit.call(exit => exit.transition().duration(linksUpdateDuration).attr("opacity", 0).remove());
|
||||
|
||||
primaryLinks.selectAll("path").data(root.links(), getLinkKey).join(linkEnter, linkUpdate, linkExit);
|
||||
secondaryLinks.selectAll("path").data(getSecondaryLinks(root), getLinkKey).join(linkEnter, linkUpdate, linkExit);
|
||||
|
||||
// new layout: move nodes with links to new positions
|
||||
const treeWidth = root.leaves().length * 50;
|
||||
const treeHeight = root.height * 50;
|
||||
|
||||
const w = treeWidth - MARGINS.left - MARGINS.right;
|
||||
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
|
||||
|
||||
const treeLayout = d3.tree().size([w, h]);
|
||||
treeLayout(root.sort((a, b) => getSortIndex(a) - getSortIndex(b)));
|
||||
|
||||
primaryLinks
|
||||
.selectAll("path")
|
||||
.data(root.links(), getLinkKey)
|
||||
.transition()
|
||||
.duration(moveDuration)
|
||||
.delay(linksUpdateDuration)
|
||||
.attr("d", getLinkPath);
|
||||
|
||||
secondaryLinks
|
||||
.selectAll("path")
|
||||
.data(getSecondaryLinks(root), getLinkKey)
|
||||
.transition()
|
||||
.duration(moveDuration)
|
||||
.delay(linksUpdateDuration)
|
||||
.attr("d", getLinkPath);
|
||||
|
||||
nodes
|
||||
.selectAll("g")
|
||||
.data(root.descendants(), getNodeKey)
|
||||
.transition()
|
||||
.delay(linksUpdateDuration)
|
||||
.duration(moveDuration)
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
function selectElement(d) {
|
||||
const dataElement = d.data;
|
||||
if (d.id == 0) return;
|
||||
|
||||
const node = nodes.select(`g[data-id="${d.id}"]`);
|
||||
nodes.selectAll("g").style("outline", "none");
|
||||
node.style("outline", "1px solid #c13119");
|
||||
|
||||
byId("hierarchyTree_selected").style.display = "block";
|
||||
byId("hierarchyTree_infoLine").style.display = "none";
|
||||
|
||||
byId("hierarchyTree_selectedName").innerText = dataElement.name;
|
||||
byId("hierarchyTree_selectedCode").value = dataElement.code;
|
||||
|
||||
byId("hierarchyTree_selectedCode").onchange = function () {
|
||||
if (this.value.length > 3) return tip("Abbreviation must be 3 characters or less", false, "error", 3000);
|
||||
if (!this.value.length) return tip("Abbreviation cannot be empty", false, "error", 3000);
|
||||
|
||||
node.select("text").text(this.value);
|
||||
dataElement.code = this.value;
|
||||
};
|
||||
|
||||
const createOriginButtons = () => {
|
||||
byId("hierarchyTree_selectedOrigins").innerHTML = dataElement.origins
|
||||
.filter(origin => origin)
|
||||
.map((origin, index) => {
|
||||
const {name, code} = validElements.find(r => r.i === origin) || {};
|
||||
const type = index ? "Secondary" : "Primary";
|
||||
const tip = `${type} origin: ${name}. Click to remove link to that origin`;
|
||||
return `<button data-id="${origin}" class="hierarchyTree_selectedButton hierarchyTree_selectedOrigin" data-tip="${tip}">${code}</button>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
byId("hierarchyTree_selectedOrigins").onclick = event => {
|
||||
const target = event.target;
|
||||
if (target.tagName !== "BUTTON") return;
|
||||
const origin = Number(target.dataset.id);
|
||||
const filtered = dataElement.origins.filter(elementOrigin => elementOrigin !== origin);
|
||||
dataElement.origins = filtered.length ? filtered : [0];
|
||||
target.remove();
|
||||
updateTree();
|
||||
};
|
||||
};
|
||||
|
||||
createOriginButtons();
|
||||
|
||||
byId("hierarchyTree_selectedSelectButton").onclick = () => {
|
||||
const origins = dataElement.origins;
|
||||
|
||||
const descendants = d.descendants().map(d => d.data.i);
|
||||
const selectableElements = validElements.filter(({i}) => !descendants.includes(i));
|
||||
|
||||
const selectableElementsHtml = selectableElements.map(({i, name, code, color}) => {
|
||||
const isPrimary = origins[0] === i ? "checked" : "";
|
||||
const isChecked = origins.includes(i) ? "checked" : "";
|
||||
|
||||
if (i === 0) {
|
||||
return /*html*/ `
|
||||
<div ${isChecked}>
|
||||
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
|
||||
Top level
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return /*html*/ `
|
||||
<div ${isChecked}>
|
||||
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
|
||||
<input data-id="${i}" id="selectElementOrigin${i}" class="checkbox" type="checkbox" ${isChecked} />
|
||||
<label data-tip="Check to set as a secondary origin" for="selectElementOrigin${i}" class="checkbox-label">
|
||||
<fill-box fill="${color}" size=".8em" disabled></fill-box>
|
||||
${code}: ${name}</label>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
byId("hierarchyTree_originSelector").innerHTML = /*html*/ `
|
||||
<form style="max-height: 35vh">
|
||||
${selectableElementsHtml.join("")}
|
||||
</form>
|
||||
`;
|
||||
|
||||
$("#hierarchyTree_originSelector").dialog({
|
||||
title: "Select origins",
|
||||
position: {my: "center", at: "center", of: "svg"},
|
||||
buttons: {
|
||||
Select: () => {
|
||||
$("#hierarchyTree_originSelector").dialog("close");
|
||||
const $selector = byId("hierarchyTree_originSelector");
|
||||
const selectedRadio = $selector.querySelector("input[type='radio']:checked");
|
||||
const selectedCheckboxes = $selector.querySelectorAll("input[type='checkbox']:checked");
|
||||
|
||||
const primary = selectedRadio ? Number(selectedRadio.value) : 0;
|
||||
const secondary = Array.from(selectedCheckboxes)
|
||||
.map(input => Number(input.dataset.id))
|
||||
.filter(origin => origin !== primary);
|
||||
|
||||
dataElement.origins = [primary, ...secondary];
|
||||
|
||||
updateTree();
|
||||
createOriginButtons();
|
||||
},
|
||||
Cancel: () => {
|
||||
$("#hierarchyTree_originSelector").dialog("close");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
byId("hierarchyTree_selectedCloseButton").onclick = () => {
|
||||
node.style("outline", "none");
|
||||
byId("hierarchyTree_selected").style.display = "none";
|
||||
byId("hierarchyTree_infoLine").style.display = "block";
|
||||
};
|
||||
}
|
||||
|
||||
function handleNoteEnter(d) {
|
||||
if (d.depth === 0) return;
|
||||
|
||||
this.classList.add("selected");
|
||||
onNodeEnter(d);
|
||||
|
||||
byId("hierarchyTree_infoLine").innerText = getDescription(d.data);
|
||||
tip("Drag to other node to add parent, click to edit");
|
||||
}
|
||||
|
||||
function handleNodeExit(d) {
|
||||
this.classList.remove("selected");
|
||||
onNodeLeave(d);
|
||||
|
||||
byId("hierarchyTree_infoLine").innerHTML = "‍";
|
||||
tip("");
|
||||
}
|
||||
|
||||
function dragToReorigin(from) {
|
||||
if (from.id == 0) return;
|
||||
|
||||
dragLine.attr("d", `M${from.x},${from.y}L${from.x},${from.y}`);
|
||||
|
||||
d3.event.on("drag", () => {
|
||||
dragLine.attr("d", `M${from.x},${from.y}L${d3.event.x},${d3.event.y}`);
|
||||
});
|
||||
|
||||
d3.event.on("end", function () {
|
||||
dragLine.attr("d", "");
|
||||
const selected = nodes.select("g.selected");
|
||||
if (!selected.size()) return;
|
||||
|
||||
const elementId = from.data.i;
|
||||
const newOrigin = selected.datum().data.i;
|
||||
if (elementId === newOrigin) return; // dragged to itself
|
||||
if (from.data.origins.includes(newOrigin)) return; // already a child of the selected node
|
||||
if (from.descendants().some(node => node.data.i === newOrigin)) return; // cannot be a child of its own child
|
||||
|
||||
const element = dataElements.find(({i}) => i === elementId);
|
||||
if (!element) return;
|
||||
|
||||
if (element.origins[0] === 0) element.origins = [];
|
||||
element.origins.push(newOrigin);
|
||||
|
||||
selectElement(from);
|
||||
updateTree();
|
||||
});
|
||||
}
|
||||
75
src/modules/dynamic/installation.js
Normal file
75
src/modules/dynamic/installation.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import {tip} from "/src/scripts/tooltips";
|
||||
|
||||
// module to prompt PWA installation
|
||||
let installButton = null;
|
||||
let deferredPrompt = null;
|
||||
|
||||
export function init(event) {
|
||||
const dontAskforInstallation = localStorage.getItem("installationDontAsk");
|
||||
if (dontAskforInstallation) return;
|
||||
|
||||
installButton = createButton();
|
||||
deferredPrompt = event;
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
tip("Application is installed", false, "success", 8000);
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
function createButton() {
|
||||
const button = document.createElement("button");
|
||||
button.style = `
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
padding: 0.6em 0.8em;
|
||||
width: auto;
|
||||
`;
|
||||
button.className = "options glow";
|
||||
button.innerHTML = "Install";
|
||||
button.onclick = openDialog;
|
||||
button.onmouseenter = () => tip("Install the Application");
|
||||
document.querySelector("body").appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
alertMessage.innerHTML = /* html */ `You can install the tool so that it will look and feel like desktop application:
|
||||
have its own icon on your home screen and work offline with some limitations
|
||||
`;
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
title: "Install the Application",
|
||||
width: "38em",
|
||||
buttons: {
|
||||
Install: function () {
|
||||
$(this).dialog("close");
|
||||
deferredPrompt.prompt();
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
},
|
||||
open: function () {
|
||||
const checkbox =
|
||||
'<span><input id="dontAsk" class="checkbox" type="checkbox"><label for="dontAsk" class="checkbox-label dontAsk"><i>do not ask again</i></label><span>';
|
||||
const pane = this.parentElement.querySelector(".ui-dialog-buttonpane");
|
||||
pane.insertAdjacentHTML("afterbegin", checkbox);
|
||||
},
|
||||
close: function () {
|
||||
const box = this.parentElement.querySelector(".checkbox");
|
||||
if (box?.checked) {
|
||||
localStorage.setItem("installationDontAsk", true);
|
||||
cleanup();
|
||||
}
|
||||
$(this).dialog("destroy");
|
||||
}
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
installButton.remove();
|
||||
installButton = null;
|
||||
deferredPrompt = null;
|
||||
}
|
||||
}
|
||||
703
src/modules/dynamic/overview/charts-overview.js
Normal file
703
src/modules/dynamic/overview/charts-overview.js
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
import {isWater} from "/src/utils/graphUtils";
|
||||
import {tip} from "/src/scripts/tooltips";
|
||||
import {byId} from "/src/utils/shorthands";
|
||||
import {rn} from "/src/utils/numberUtils";
|
||||
import {capitalize} from "/src/utils/stringUtils";
|
||||
import {si, convertTemperature} from "/src/utils/unitUtils";
|
||||
import {rollups} from "/src/utils/functionUtils";
|
||||
|
||||
const entitiesMap = {
|
||||
states: {
|
||||
label: "State",
|
||||
getCellsData: () => pack.cells.state,
|
||||
getName: nameGetter("states"),
|
||||
getColors: colorsGetter("states"),
|
||||
landOnly: true
|
||||
},
|
||||
cultures: {
|
||||
label: "Culture",
|
||||
getCellsData: () => pack.cells.culture,
|
||||
getName: nameGetter("cultures"),
|
||||
getColors: colorsGetter("cultures"),
|
||||
landOnly: true
|
||||
},
|
||||
religions: {
|
||||
label: "Religion",
|
||||
getCellsData: () => pack.cells.religion,
|
||||
getName: nameGetter("religions"),
|
||||
getColors: colorsGetter("religions"),
|
||||
landOnly: true
|
||||
},
|
||||
provinces: {
|
||||
label: "Province",
|
||||
getCellsData: () => pack.cells.province,
|
||||
getName: nameGetter("provinces"),
|
||||
getColors: colorsGetter("provinces"),
|
||||
landOnly: true
|
||||
},
|
||||
biomes: {
|
||||
label: "Biome",
|
||||
getCellsData: () => 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) + "%"}
|
||||
};
|
||||
|
||||
let charts = []; // store charts data
|
||||
let prevMapId = mapId;
|
||||
|
||||
appendStyleSheet();
|
||||
insertHtml();
|
||||
addListeners();
|
||||
changeViewColumns();
|
||||
|
||||
export function open() {
|
||||
closeDialogs("#chartsOverview, .stable");
|
||||
|
||||
if (prevMapId !== mapId) {
|
||||
charts = [];
|
||||
prevMapId = mapId;
|
||||
}
|
||||
|
||||
if (!charts.length) addChart();
|
||||
else charts.forEach(chart => renderChart(chart));
|
||||
|
||||
$("#chartsOverview").dialog({
|
||||
title: "Data Charts",
|
||||
position: {my: "center", at: "center", of: "svg"},
|
||||
close: handleClose
|
||||
});
|
||||
}
|
||||
|
||||
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]) => `<option value="${value}">${label}</option>`;
|
||||
const createOptions = values => values.map(createOption).join("");
|
||||
|
||||
const html = /* html */ `<div id="chartsOverview" class="dialog stable">
|
||||
<form id="chartsOverview__form">
|
||||
<div>
|
||||
<button data-tip="Add a chart" type="submit">Plot</button>
|
||||
|
||||
<select data-tip="Select entity (y axis)" id="chartsOverview__entitiesSelect">
|
||||
${createOptions(entities)}
|
||||
</select>
|
||||
|
||||
<label>by
|
||||
<select data-tip="Select value to plot by (x axis)" id="chartsOverview__plotBySelect">
|
||||
${createOptions(plotBy)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>grouped by
|
||||
<select data-tip="Select entoty to group by. If you don't need grouping, set it the same as the entity" id="chartsOverview__groupBySelect">
|
||||
${createOptions(entities)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label data-tip="Sorting type">sorted
|
||||
<select id="chartsOverview__sortingSelect">
|
||||
<option value="value">by value</option>
|
||||
<option value="name">by name</option>
|
||||
<option value="natural">naturally</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<span data-tip="Chart type">Type</span>
|
||||
<select id="chartsOverview__chartType">
|
||||
<option value="stackedBar" selected>Stacked Bar</option>
|
||||
<option value="normalizedStackedBar">Normalized Stacked Bar</option>
|
||||
</select>
|
||||
|
||||
<span data-tip="Columns to display">Columns</span>
|
||||
<select id="chartsOverview__viewColumns">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="chartsOverview__charts"></section>
|
||||
</div>`;
|
||||
|
||||
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", addChart);
|
||||
byId("chartsOverview__viewColumns").on("change", changeViewColumns);
|
||||
}
|
||||
|
||||
function addChart(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 {stackable} = quantizationMap[plotBy];
|
||||
|
||||
if (!stackable && groupBy !== entity) {
|
||||
tip(`Grouping is not supported for ${plotByLabel}`, false, "warn", 4000);
|
||||
groupBy = entity;
|
||||
}
|
||||
|
||||
const chartOptions = {id: Date.now(), entity, plotBy, groupBy, sorting, type};
|
||||
charts.push(chartOptions);
|
||||
renderChart(chartOptions);
|
||||
updateDialogPosition();
|
||||
}
|
||||
|
||||
function renderChart({id, entity, plotBy, groupBy, sorting, type}) {
|
||||
const {
|
||||
label: plotByLabel,
|
||||
stringify,
|
||||
quantize,
|
||||
aggregate,
|
||||
formatTicks,
|
||||
landOnly: plotByLandOnly
|
||||
} = quantizationMap[plotBy];
|
||||
|
||||
const noGrouping = groupBy === entity;
|
||||
|
||||
const {
|
||||
label: entityLabel,
|
||||
getName: getEntityName,
|
||||
getCellsData: getEntityCellsData,
|
||||
landOnly: entityLandOnly
|
||||
} = entitiesMap[entity];
|
||||
const {label: groupLabel, getName: getGroupName, getCellsData: getGroupCellsData, getColors} = entitiesMap[groupBy];
|
||||
|
||||
const entityCells = getEntityCellsData();
|
||||
const groupCells = getGroupCellsData();
|
||||
|
||||
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(id, $chart, title);
|
||||
|
||||
byId("chartsOverview__charts").lastChild.scrollIntoView();
|
||||
}
|
||||
|
||||
// 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(id, $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 */ `
|
||||
<div>
|
||||
<strong>Figure ${figureNo}</strong>. ${title}
|
||||
</div>
|
||||
<div>
|
||||
<button data-tip="Download the chart in svg format (can open in browser or Inkscape)" class="icon-download"></button>
|
||||
<button data-tip="Remove the chart" class="icon-trash"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$figure.appendChild($chart);
|
||||
$figure.appendChild($caption);
|
||||
$chartContainer.appendChild($figure);
|
||||
|
||||
const downloadChart = () => {
|
||||
const name = `${getFileName(title)}.svg`;
|
||||
downloadFile($chart.outerHTML, name);
|
||||
};
|
||||
|
||||
const removeChart = () => {
|
||||
$figure.remove();
|
||||
charts = charts.filter(chart => chart.id !== id);
|
||||
updateDialogPosition();
|
||||
};
|
||||
|
||||
$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)`;
|
||||
updateDialogPosition();
|
||||
}
|
||||
|
||||
function updateDialogPosition() {
|
||||
$("#chartsOverview").dialog({position: {my: "center", at: "center", of: "svg"}});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
const $chartContainer = byId("chartsOverview__charts");
|
||||
$chartContainer.innerHTML = "";
|
||||
$("#chartsOverview").dialog("destroy");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
44
src/modules/dynamic/supporters.js
Normal file
44
src/modules/dynamic/supporters.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import {capitalize} from "../../utils/stringUtils";
|
||||
|
||||
const format = rawList =>
|
||||
rawList
|
||||
.replace(/(?:\r\n|\r|\n)/g, "")
|
||||
.split(",")
|
||||
.map(name => capitalize(name.trim()))
|
||||
.sort();
|
||||
|
||||
export const supporters = format(`
|
||||
Aaron Meyer,Ahmad Amerih,AstralJacks,aymeric,Billy Dean Goehring,Branndon Edwards,Chase Mayers,Curt Flood,cyninge,Dino Princip,
|
||||
E.M. White,es,Fondue,Fritjof Olsson,Gatsu,Johan Fröberg,Jonathan Moore,Joseph Miranda,Kate,KC138,Luke Nelson,Markus Finster,Massimo Vella,Mikey,
|
||||
Nathan Mitchell,Paavi1,Pat,Ryan Westcott,Sasquatch,Shawn Spencer,Sizz_TV,Timothée CALLET,UTG community,Vlad Tomash,Wil Sisney,William Merriott,
|
||||
Xariun,Gun Metal Games,Scott Marner,Spencer Sherman,Valerii Matskevych,Alloyed Clavicle,Stewart Walsh,Ruthlyn Mollett (Javan),Benjamin Mair-Pratt,
|
||||
Diagonath,Alexander Thomas,Ashley Wilson-Savoury,William Henry,Preston Brooks,JOSHUA QUALTIERI,Hilton Williams,Katharina Haase,Hisham Bedri,
|
||||
Ian arless,Karnat,Bird,Kevin,Jessica Thomas,Steve Hyatt,Logicspren,Alfred García,Jonathan Killstring,John Ackley,Invad3r233,Norbert Žigmund,Jennifer,
|
||||
PoliticsBuff,_gfx_,Maggie,Connor McMartin,Jared McDaris,BlastWind,Franc Casanova Ferrer,Dead & Devil,Michael Carmody,Valerie Elise,naikibens220,
|
||||
Jordon Phillips,William Pucs,The Dungeon Masters,Brady R Rathbun,J,Shadow,Matthew Tiffany,Huw Williams,Joseph Hamilton,FlippantFeline,Tamashi Toh,
|
||||
kms,Stephen Herron,MidnightMoon,Whakomatic x,Barished,Aaron bateson,Brice Moss,Diklyquill,PatronUser,Michael Greiner,Steven Bennett,Jacob Harrington,
|
||||
Miguel C.,Reya C.,Giant Monster Games,Noirbard,Brian Drennen,Ben Craigie,Alex Smolin,Endwords,Joshua E Goodwin,SirTobit ,Allen S. Rout,Allen Bull Bear,
|
||||
Pippa Mitchell,R K,G0atfather,Ryan Lege,Caner Oleas Pekgönenç,Bradley Edwards,Tertiary ,Austin Miller,Jesse Holmes,Jan Dvořák,Marten F,Erin D. Smale,
|
||||
Maxwell Hill,Drunken_Legends,rob bee,Jesse Holmes,YYako,Detocroix,Anoplexian,Hannah,Paul,Sandra Krohn,Lucid,Richard Keating,Allen Varney,Rick Falkvinge,
|
||||
Seth Fusion,Adam Butler,Gus,StroboWolf,Sadie Blackthorne,Zewen Senpai,Dell McKnight,Oneiris,Darinius Dragonclaw Studios,Christopher Whitney,Rhodes HvZ,
|
||||
Jeppe Skov Jensen,María Martín López,Martin Seeger,Annie Rishor,Aram Sabatés,MadNomadMedia,Eric Foley,Vito Martono,James H. Anthony,Kevin Cossutta,
|
||||
Thirty-OneR,ThatGuyGW,Dee Chiu,MontyBoosh,Achillain,Jaden,SashaTK,Steve Johnson,Pierrick Bertrand,Jared Kennedy,Dylan Devenny,Kyle Robertson,
|
||||
Andrew Rostaing,Daniel Gill,Char,Jack,Barna Csíkos,Ian Rousseau,Nicholas Grabstas,Tom Van Orden jr,Bryan Brake,Akylos,Riley Seaman,MaxOliver,Evan-DiLeo,
|
||||
Alex Debus,Joshua Vaught,Kyle S,Eric Moore,Dean Dunakin,Uniquenameosaurus,WarWizardGames,Chance Mena,Jan Ka,Miguel Alejandro,Dalton Clark,Simon Drapeau,
|
||||
Radovan Zapletal,Jmmat6,Justa Badge,Blargh Blarghmoomoo,Vanessa Anjos,Grant A. Murray,Akirsop,Rikard Wolff,Jake Fish,teco 47,Antiroo,Jakob Siegel,
|
||||
Guilherme Aguiar,Jarno Hallikainen,Justin Mcclain,Kristin Chernoff,Rowland Kingman,Esther Busch,Grayson McClead,Austin,Hakon the Viking,Chad Riley,
|
||||
Cooper Counts,Patrick Jones,Clonetone,PlayByMail.Net,Brad Wardell,Lance Saba,Egoensis,Brea Richards,Tiber,Chris Bloom,Maxim Lowe,Aquelion,
|
||||
Page One Project,Spencer Morris,Paul Ingram,Dust Bunny,Adrian Wright,Eric Alexander Cartaya,GameNight,Thomas Mortensen Hansen,Zklaus,Drinarius,
|
||||
Ed Wright,Lon Varnadore,Crys Cain,Heaven N Lee,Jeffrey Henning,Lazer Elf,Jordan Bellah,Alex Beard,Kass Frisson,Petro Lombaard,Emanuel Pietri,Rox,
|
||||
PinkEvil,Gavin Madrigal,Martin Lorber,Prince of Morgoth,Jaryd Armstrong,Andrew Pirkola,ThyHolyDevil,Gary Smith,Tyshaun Wise,Ethan Cook,Jon Stroman,
|
||||
Nobody679,良义 金,Chris Gray,Phoenix Boatwright,Mackenzie,Milo Cohen,Jason Matthew Wuerfel,Rasmus Legêne,Andrew Hines,Wexxler,Espen Sæverud,Binks,
|
||||
Dominick Ormsby,Linn Browning,Václav Švec,Alan Buehne,George J.Lekkas,Alexandre Boivin,Tommy Mayfield,Skylar Mangum-Turner,Karen Blythe,Stefan Gugerel,
|
||||
Mike Conley,Xavier privé,Hope You're Well,Mark Sprietsma,Robert Landry,Nick Mowry,steve hall,Markell,Josh Wren,Neutrix,BLRageQuit,Rocky,
|
||||
Dario Spadavecchia,Bas Kroot,John Patrick Callahan Jr,Alexandra Vesey,D,Exp1nt,james,Braxton Istace,w,Rurikid,AntiBlock,Redsauz,BigE0021,
|
||||
Jonathan Williams,ojacid .,Brian Wilson,A Patreon of the Ahts,Shubham Jakhotiya,www15o,Jan Bundesmann,Angelique Badger,Joshua Xiong,Moist mongol,
|
||||
Frank Fewkes,jason baldrick,Game Master Pro,Andrew Kircher,Preston Mitchell,Chris Kohut,Emarandzeb,Trentin Bergeron,Damon Gallaty,Pleaseworkforonce,
|
||||
Jordan,William Markus,Sidr Dim,Alexander Whittaker,The Next Level,Patrick Valverde,Markus Peham,Daniel Cooper,the Beagles of Neorbus,Marley Moule,
|
||||
Maximilian Schielke,Johnathan Xavier Hutchinson,Ele,Rita,Randy Ross,John Wick,RedSpaz,cameron cannon,Ian Grau-Fay,Kyle Barrett,Charlotte Wiland,
|
||||
David Kaul,E. Jason Davis,Cyberate,Atenfox,Sea Wolf,Holly Loveless,Roekai,Alden Z,angel carrillo,Sam Spoerle,S A Rudy,Bird Law Expert,Mira Cyr,
|
||||
Aaron Blair,Neyimadd,RLKZ1022,DerWolf,Kenji Yamada,Zion,Robert Rinne,Actual_Dio,Kyarou
|
||||
`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue