[Migration] NPM (#1266)

* chore: add npm + vite for progressive enhancement

* fix: update Dockerfile to copy only the dist folder contents

* fix: update Dockerfile to use multi-stage build for optimized production image

* fix: correct nginx config file copy command in Dockerfile

* chore: add netlify configuration for build and redirects

* fix: add NODE_VERSION to environment in Netlify configuration

* remove wrong dist folder

* Update package.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: split public and src

* migrating all util files from js to ts

* feat: Implement HeightmapGenerator and Voronoi module

- Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.).
- Introduced Voronoi class for creating Voronoi diagrams using Delaunator.
- Updated index.html to include new modules.
- Created index.ts to manage module imports.
- Enhanced arrayUtils and graphUtils with type definitions and improved functionality.
- Added utility functions for generating grids and calculating Voronoi cells.

* chore: add GitHub Actions workflow for deploying to GitHub Pages

* fix: update branch name in GitHub Actions workflow from 'main' to 'master'

* chore: update package.json to specify Node.js engine version and remove unused launch.json

* Initial plan

* Update copilot guidelines to reflect NPM/Vite/TypeScript migration

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/utils/graphUtils.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: Add TIME and ERROR variables to global scope in HeightmapGenerator

* fix: Update base path in vite.config.ts for Netlify deployment

* fix: Update Node.js version in Dockerfile to 24-alpine

---------

Co-authored-by: Marc Emmanuel <marc.emmanuel@tado.com>
Co-authored-by: Marc Emmanuel <marcwissler@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
This commit is contained in:
Azgaar 2026-01-22 12:20:12 +01:00 committed by GitHub
parent 0c26f0831f
commit 9e0eb03618
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
713 changed files with 5182 additions and 2161 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,950 @@
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 9em 4em 8em 5em 7em 8em">
<div data-tip="Click to sort by culture name" class="sortable alphabetically" data-sortby="name">Culture&nbsp;</div>
<div data-tip="Click to sort by type" class="sortable alphabetically" data-sortby="type">Type&nbsp;</div>
<div data-tip="Click to sort by culture namesbase" class="sortable" data-sortby="base">Namesbase&nbsp;</div>
<div data-tip="Click to sort by culture cells count" class="sortable hide" data-sortby="cells">Cells&nbsp;</div>
<div data-tip="Click to sort by expansionism" class="sortable hide" data-sortby="expansionism">Expansion&nbsp;</div>
<div data-tip="Click to sort by culture area" class="sortable hide" data-sortby="area">Area&nbsp;</div>
<div data-tip="Click to sort by culture population" class="sortable hide icon-sort-number-down" data-sortby="population">Population&nbsp;</div>
<div data-tip="Click to sort by culture emblems shape" class="sortable alphabetically hide" data-sortby="emblems">Emblems&nbsp;</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:&nbsp;<span id="culturesFooterCultures">0</span></div>
<div data-tip="Total land cells number" style="margin-left: 12px">Cells:&nbsp;<span id="culturesFooterCells">0</span></div>
<div data-tip="Total land area" style="margin-left: 12px">Land Area:&nbsp;<span id="culturesFooterArea">0</span></div>
<div data-tip="Total population" style="margin-left: 12px">Population:&nbsp;<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">
<div data-tip="Change brush size. Shortcut: + to increase; to decrease" style="margin-block: 0.3em;">
<slider-input id="culturesBrush" min="1" max="100" value="15">Brush size:</slider-input>
</div>
<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>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
<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: 4em">${si(population)}</div>
${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>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
<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: 4em">${si(population)}</div>
${getShapeOptions(selectShape, c.shield)}
<span data-tip="Lock culture" class="icon-lock${c.lock ? "" : "-open"} hide"></span>
<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("change", 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));
$body.querySelectorAll("div > span.icon-lock").forEach($el => $el.on("click", updateLockStatus));
$body.querySelectorAll("div > span.icon-lock-open").forEach($el => $el.on("click", updateLockStatus));
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: fitContent()});
}
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>`));
if (!nameBases[base]) options += `<option selected value="${base}">removed</option>`; // in case namesbase was removed
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", 3)
.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", 2)
.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);
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 cultureId = +this.parentNode.dataset.id;
const base = pack.cultures[cultureId].base;
if (!nameBases[base]) return tip("Namesbase is not defined, please select a valid namesbase", false, "error", 5000);
const name = Names.getCultureShort(cultureId);
this.parentNode.querySelector("input.cultureName").value = name;
pack.cultures[cultureId].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));
}
if (layerIsOn("togglePopulation")) drawPopulation();
refreshCulturesEditor();
}
function cultureRegenerateBurgs() {
if (customization === 4) return;
const cultureId = +this.parentNode.dataset.id;
const base = pack.cultures[cultureId].base;
if (!nameBases[base]) return tip("Namesbase is not defined, please select a valid namesbase", false, "error", 5000);
const cultureBurgs = pack.burgs.filter(b => b.culture === cultureId && !b.removed && !b.lock);
cultureBurgs.forEach(b => {
b.name = Names.getCulture(cultureId);
labels.select("[data-id='" + b.i + "']").text(b.name);
});
tip(`Names for ${cultureBurgs.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", 0.8)
.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", 2)
.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 cultureId = +this.id.slice(13);
const tr = parseTransform(this.getAttribute("transform"));
const x0 = +tr[0] - d3.event.x;
const y0 = +tr[1] - d3.event.y;
function handleDrag() {
const {x, y} = d3.event;
this.setAttribute("transform", `translate(${x0 + x},${y0 + y})`);
const cell = findCell(x, y);
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
pack.cultures[cultureId].center = cell;
recalculateCultures();
}
const dragDebounced = debounce(handleDrag, 50);
d3.event.on("drag", dragDebounced);
}
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.88.06");
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(force) {
if (force || culturesAutoChange.checked) {
Cultures.expand();
drawCultures();
pack.burgs.forEach(b => (b.culture = pack.cells.culture[b.cell]));
refreshCulturesEditor();
}
}
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 = +culturesBrush.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 = +culturesBrush.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 file = this.files[0];
this.value = "";
const csv = await file.text();
const data = d3.csvParse(csv, d => ({
name: d.Name,
i: +d.Id,
color: d.Color,
expansionism: +d.Expansionism,
type: d.Type,
population: +d.Population,
emblemsShape: d["Emblems Shape"],
origins: d.Origins,
namesbase: d.Namesbase
}));
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 culture of data) {
let current;
if (culture.i < cultures.length) {
current = cultures[culture.i];
const ratio = current.urban / (current.rural + current.urban);
applyPopulationChange(
current.rural,
current.urban,
culture.population * (1 - ratio),
culture.population * ratio,
culture.i
);
} else {
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origins: [0], rural: 0, urban: 0};
cultures.push(current);
}
current.removed = false;
current.name = culture.name;
if (current.i) {
current.code = abbreviate(
current.name,
cultures.map(c => c.code)
);
current.color = culture.color;
current.expansionism = +culture.expansionism;
if (cultureTypes.includes(culture.type)) current.type = culture.type;
else current.type = "Generic";
}
culture.origins = current.i ? restoreOrigins(culture.origins || "") : [null];
current.shield = shapes.includes(culture.emblemsShape) ? culture.emblemsShape : "heater";
current.base = nameBases.findIndex(n => n.name == culture.namesbase); // can be -1 if namesbase is not found
function restoreOrigins(originsString) {
const originNames = originsString
.replaceAll('"', "")
.split(",")
.map(s => s.trim())
.filter(s => s);
const originIds = originNames.map(name => {
const id = cultures.findIndex(c => c.name === name);
return id === -1 ? null : id;
});
current.origins = originIds.filter(id => id !== null);
if (!current.origins.length) current.origins = [0];
}
}
cultures.filter(c => c.removed).forEach(c => removeCulture(c.i));
drawCultures();
refreshCulturesEditor();
}
function updateLockStatus() {
if (customization) return;
const cultureId = +this.parentNode.dataset.id;
const classList = this.classList;
const c = pack.cultures[cultureId];
c.lock = !c.lock;
classList.toggle("icon-lock-open");
classList.toggle("icon-lock");
}

View file

@ -0,0 +1,844 @@
const $body = insertEditorHtml();
addListeners();
export function open() {
closeDialogs("#religionsEditor, .stable");
if (!layerIsOn("toggleReligions")) toggleReligions();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleCultures")) toggleCultures();
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 6em 7em 6em 7em">
<div data-tip="Click to sort by religion name" class="sortable alphabetically" data-sortby="name">Religion&nbsp;</div>
<div data-tip="Click to sort by religion type" class="sortable alphabetically icon-sort-name-down" data-sortby="type">Type&nbsp;</div>
<div data-tip="Click to sort by religion form" class="sortable alphabetically" data-sortby="form">Form&nbsp;</div>
<div data-tip="Click to sort by supreme deity" class="sortable alphabetically hide" data-sortby="deity">Supreme Deity&nbsp;</div>
<div data-tip="Click to sort by religion area" class="sortable hide" data-sortby="area">Area&nbsp;</div>
<div data-tip="Click to sort by number of believers (religion area population)" class="sortable hide" data-sortby="population">Believers&nbsp;</div>
<div data-tip="Click to sort by potential extent type" class="sortable alphabetically hide" data-sortby="expansion">Potential&nbsp;</div>
<div data-tip="Click to sort by expansionism" class="sortable hide" data-sortby="expansionism">Expansion&nbsp;</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:&nbsp;<span id="religionsOrganized">0</span>
</div>
<div data-tip="Total number of heresies" style="margin-left: 12px">
Heresies:&nbsp;<span id="religionsHeresies">0</span>
</div>
<div data-tip="Total number of cults" style="margin-left: 12px">
Cults:&nbsp;<span id="religionsCults">0</span>
</div>
<div data-tip="Total number of folk religions" style="margin-left: 12px">
Folk:&nbsp;<span id="religionsFolk">0</span>
</div>
<div data-tip="Total land area" style="margin-left: 12px">
Land Area:&nbsp;<span id="religionsFooterArea">0</span>
</div>
<div data-tip="Total number of believers (population)" style="margin-left: 12px">
Believers:&nbsp;<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">
<div data-tip="Change brush size. Shortcut: + to increase; to decrease" style="margin-block: 0.3em;">
<slider-input id="religionsBrush" min="1" max="100" value="15">Brush size:</slider-input>
</div>
<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>
<button id="religionsRecalculate" data-tip="Recalculate religions based on current values of growth-related attributes" class="icon-retweet"></button>
<span data-tip="Allow religion center, extent, and expansionism changes to take an immediate effect">
<input id="religionsAutoChange" class="checkbox" type="checkbox" />
<label for="religionsAutoChange" class="checkbox-label"><i>auto-apply changes</i></label>
</span>
</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);
byId("religionsRecalculate").on("click", () => recalculateReligions(true));
}
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-expansion=""
data-expansionism=""
>
<svg width="9" height="9" 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" 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: 6em">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="religionPopulation hide pointer" style="width: 5em">${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-expansion="${r.expansion}"
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" 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: 6em">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="religionPopulation hide pointer" style="width: 5em">${si(
population
)}</div>
${getExpansionColumns(r)}
<span data-tip="Lock this religion" class="icon-lock${r.lock ? "" : "-open"} hide"></span>
<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 > select.religionExtent").forEach(el => el.on("change", religionChangeExtent));
$body.querySelectorAll("div > input.religionExpantion").forEach(el => el.on("change", religionChangeExpansionism));
$body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", religionRemovePrompt));
$body.querySelectorAll("div > span.icon-lock").forEach($el => $el.on("click", updateLockStatus));
$body.querySelectorAll("div > span.icon-lock-open").forEach($el => $el.on("click", updateLockStatus));
if ($body.dataset.type === "percentage") {
$body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(religionsHeader);
$("#religionsEditor").dialog({width: fitContent()});
}
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;
}
function getExpansionColumns(r) {
if (r.type === "Folk") {
const tip =
"Folk religions are not competitive and do not expand. Initially they cover all cells of their parent culture, but get ousted by organized religions when they expand";
return /* html */ `
<span data-tip="${tip}" class="icon-resize-full-alt hide" style="padding-right: 2px"></span>
<span data-tip="${tip}" class="religionExtent hide" style="width: 5em">culture</span>
<span data-tip="${tip}" class="icon-resize-full hide"></span>
<input data-tip="${tip}" class="religionExpantion hide" disabled type="number" value='0' />`;
}
return /* html */ `
<span data-tip="Potential religion extent" class="icon-resize-full-alt hide" style="padding-right: 2px"></span>
<select data-tip="Potential religion extent" class="religionExtent hide" style="width: 5em">
${getExtentOptions(r.expansion)}
</select>
<span data-tip="Religion expansionism. Defines competitive size" class="icon-resize-full hide"></span>
<input
data-tip="Religion expansionism. Defines competitive size. Click to change, then click Recalculate to apply change"
class="religionExpantion hide"
type="number"
min="0"
max="99"
step=".1"
value=${r.expansionism}
/>`;
}
function getExtentOptions(type) {
let options = "";
const types = ["global", "state", "culture"];
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(2000).ease(d3.easeSinIn);
relig
.select("#religion" + religionId)
.raise()
.transition(animate)
.attr("stroke-width", 2.5)
.attr("stroke", "#d0240f");
debug
.select("#religionsCenter" + religionId)
.raise()
.transition(animate)
.attr("r", 3)
.attr("stroke", "#d0240f");
}, 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", 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));
}
if (layerIsOn("togglePopulation")) drawPopulation();
refreshReligionsEditor();
}
}
function religionChangeExtent() {
const religion = +this.parentNode.dataset.id;
this.parentNode.dataset.expansion = this.value;
pack.religions[religion].expansion = this.value;
recalculateReligions();
}
function religionChangeExpansionism() {
const religion = +this.parentNode.dataset.id;
this.parentNode.dataset.expansionism = this.value;
pack.religions[religion].expansionism = +this.value;
recalculateReligions();
}
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", 0.8)
.attr("stroke", "#444444")
.style("cursor", "move");
let data = pack.religions.filter(r => r.i && r.center && !r.removed);
const showExtinct = $body.dataset.extinct === "show";
if (!showExtinct) data = data.filter(r => r.cells > 0);
religionCenters
.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("id", d => "religionsCenter" + d.i)
.attr("data-id", d => d.i)
.attr("r", 2)
.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 religionId = +this.dataset.id;
const tr = parseTransform(this.getAttribute("transform"));
const x0 = +tr[0] - d3.event.x;
const y0 = +tr[1] - d3.event.y;
function handleDrag() {
const {x, y} = d3.event;
this.setAttribute("transform", `translate(${x0 + x},${y0 + y})`);
const cell = findCell(x, y);
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
pack.religions[religionId].center = cell;
recalculateReligions();
}
const dragDebounced = debounce(handleDrag, 50);
d3.event.on("drag", dragDebounced);
}
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.88.06");
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();
drawReligionCenters();
}
function enterReligionsManualAssignent() {
if (!layerIsOn("toggleReligions")) toggleReligions();
customization = 7;
relig.append("g").attr("id", "temp");
document.querySelectorAll("#religionsBottom > *").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("religionsBrush").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("religionsBrush").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 > *").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,Potential,Expansionism`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, color, type, form, deity, area, population, expansion, expansionism} = $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, expansion, expansionism].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();
}
function updateLockStatus() {
if (customization) return;
const religionId = +this.parentNode.dataset.id;
const classList = this.classList;
const r = pack.religions[religionId];
r.lock = !r.lock;
classList.toggle("icon-lock-open");
classList.toggle("icon-lock");
}
function recalculateReligions(must) {
if (!must && !religionsAutoChange.checked) return;
Religions.recalculate();
drawReligions();
refreshReligionsEditor();
drawReligionCenters();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
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");
TIME && console.time("exportToJson");
const typeMap = {
Full: getFullDataJson,
Minimal: getMinimalDataJson,
PackCells: getPackDataJson,
GridCells: getGridDataJson
};
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);
TIME && console.timeEnd("exportToJson");
}
function getFullDataJson() {
const info = getMapInfo();
const settings = getSettings();
const pack = getPackCellsData();
const grid = getGridCellsData();
return JSON.stringify({
info,
settings,
mapCoordinates,
pack,
grid,
biomesData,
notes,
nameBases
});
}
function 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,
routes: pack.routes,
zones: pack.zones
};
return JSON.stringify({info, settings, mapCoordinates, pack: packData, biomesData, notes, nameBases});
}
function getPackDataJson() {
const info = getMapInfo();
const cells = getPackCellsData();
return JSON.stringify({info, cells});
}
function getGridDataJson() {
const info = getMapInfo();
const cells = getGridCellsData();
return JSON.stringify({info, cells});
}
function getMapInfo() {
return {
version: VERSION,
description: "Azgaar's Fantasy Map Generator output: azgaar.github.io/Fantasy-map-generator",
exportedAt: new Date().toISOString(),
mapName: mapName.value,
width: graphWidth,
height: graphHeight,
seed,
mapId
};
}
function getSettings() {
return {
distanceUnit: distanceUnitInput.value,
distanceScale,
areaUnit: areaUnit.value,
heightUnit: heightUnit.value,
heightExponent: heightExponentInput.value,
temperatureScale: temperatureScale.value,
populationRate: populationRate,
urbanization: urbanization,
mapSize: mapSizeOutput.value,
latitude: latitudeOutput.value,
longitude: longitudeOutput.value,
prec: precOutput.value,
options: options,
mapName: mapName.value,
hideLabels: hideLabels.checked,
stylePreset: stylePreset.value,
rescaleLabels: rescaleLabels.checked,
urbanDensity: urbanDensity
};
}
function getPackCellsData() {
const data = {
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),
routes: pack.cells.routes,
state: Array.from(pack.cells.state),
religion: Array.from(pack.cells.religion),
province: Array.from(pack.cells.province)
};
return {
cells: Array.from(pack.cells.i).map(cellId => ({
i: cellId,
v: data.v[cellId],
c: data.c[cellId],
p: data.p[cellId],
g: data.g[cellId],
h: data.h[cellId],
area: data.area[cellId],
f: data.f[cellId],
t: data.t[cellId],
haven: data.haven[cellId],
harbor: data.harbor[cellId],
fl: data.fl[cellId],
r: data.r[cellId],
conf: data.conf[cellId],
biome: data.biome[cellId],
s: data.s[cellId],
pop: data.pop[cellId],
culture: data.culture[cellId],
burg: data.burg[cellId],
routes: data.routes[cellId],
state: data.state[cellId],
religion: data.religion[cellId],
province: data.province[cellId]
})),
vertices: Array.from(pack.vertices.p).map((_, vertexId) => ({
i: vertexId,
p: pack.vertices.p[vertexId],
v: pack.vertices.v[vertexId],
c: pack.vertices.c[vertexId]
})),
features: pack.features,
cultures: pack.cultures,
burgs: pack.burgs,
states: pack.states,
provinces: pack.provinces,
religions: pack.religions,
rivers: pack.rivers,
markers: pack.markers,
routes: pack.routes,
zones: pack.zones
};
}
function getGridCellsData() {
const dataArrays = {
v: grid.cells.v,
c: grid.cells.c,
b: grid.cells.b,
f: Array.from(grid.cells.f),
t: Array.from(grid.cells.t),
h: Array.from(grid.cells.h),
temp: Array.from(grid.cells.temp),
prec: Array.from(grid.cells.prec)
};
const gridData = {
cells: Array.from(grid.cells.i).map(cellId => ({
i: cellId,
v: dataArrays.v[cellId],
c: dataArrays.c[cellId],
b: dataArrays.b[cellId],
f: dataArrays.f[cellId],
t: dataArrays.t[cellId],
h: dataArrays.h[cellId],
temp: dataArrays.temp[cellId],
prec: dataArrays.prec[cellId]
})),
vertices: Array.from(grid.vertices.p).map((_, vertexId) => ({
i: vertexId,
p: grid.vertices.p[vertexId],
v: grid.vertices.v[vertexId],
c: grid.vertices.c[vertexId]
})),
cellsDesired: grid.cellsDesired,
spacing: grid.spacing,
cellsY: grid.cellsY,
cellsX: grid.cellsX,
points: grid.points,
boundary: grid.boundary,
seed: grid.seed,
features: pack.features
};
return gridData;
}

View file

@ -0,0 +1,319 @@
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 heightmapColorSchemeOptions = Object.keys(heightmapColorSchemes)
.map(scheme => `<option value="${scheme}">${scheme}</option>`)
.join("");
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">${heightmapColorSchemeOptions}</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);
return /* html */ `<article data-id="${key}" data-seed="${initialSeed}">
<img src="${getHeightmapPreview(heights)}" 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, seed) ? generateGrid() : deepCopy(currentGraph);
delete newGraph.cells.h;
return newGraph;
}
function drawTemplatePreview(id) {
const heights = HeightmapGenerator.fromTemplate(graph, id);
const dataUrl = getHeightmapPreview(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 = getHeightmapPreview(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})
});
}
function getHeightmapPreview(heights) {
const scheme = getColorScheme(byId("heightmapSelectionColorScheme").value);
const renderOcean = byId("heightmapSelectionRenderOcean").checked;
const dataUrl = drawHeights({heights, width: graph.cellsX, height: graph.cellsY, scheme, renderOcean});
return dataUrl;
}

View file

@ -0,0 +1,526 @@
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;
validElements = cleanupOrigins(dataElements);
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();
if (!root) return;
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">&#8205;</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 cleanupOrigins(elements) {
const existingElements = elements.filter(d => !d.removed);
return existingElements.map(d => {
if (d.i === 0) d.origins = [null]; // root element
else if (!d.origins.length) d.origins = [0];
else if (!existingElements.find(el => d.origins[0] === el.i)) d.origins = [0];
return d;
});
}
function getRoot() {
try {
const root = d3
.stratify()
.id(d => d.i)
.parentId(d => d.origins[0])(validElements);
oldRoot = root;
return root;
} catch (error) {
tip("Hierarchy data issue. " + error, false, "error", 6000);
return oldRoot;
}
}
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 = "&#8205;";
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();
});
}

View file

@ -0,0 +1,73 @@
// 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.getElementById("optionsContainer").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;
}
}

View file

@ -0,0 +1,703 @@
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 sortedData = sortData(chartData, sorting);
const colors = getColors();
const {offset, formatX = formatTicks} = plotTypeMap[type];
const $chart = createStackedBarChart(sortedData, {colors, tooltip, offset, formatX});
insertChart(id, sortedData, $chart, title);
byId("chartsOverview__charts").lastChild.scrollIntoView();
}
// based on observablehq.com/@d3/stacked-horizontal-bar-chart
function createStackedBarChart(sortedData, {colors, tooltip, offset, formatX}) {
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, sortedData, $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 chart data as a text file (.csv)" class="icon-download"></button>
<button data-tip="Download the chart in svg format (can open in browser or Inkscape)" class="icon-chart-bar"></button>
<button data-tip="Remove the chart" class="icon-trash"></button>
</div>
`;
$figure.appendChild($chart);
$figure.appendChild($caption);
$chartContainer.appendChild($figure);
const downloadChartData = () => {
const name = `${getFileName(title)}.csv`;
const headers = "Name,Group,Value\n";
const values = sortedData.map(({name, group, value}) => `${name},${group},${value}`).join("\n");
downloadFile(headers + values, name);
};
const downloadChartSvg = () => {
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", downloadChartData);
$figure.querySelector("button.icon-chart-bar").on("click", downloadChartSvg);
$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;
}

View file

@ -0,0 +1,609 @@
export const supporters = `ken burgan
Sera's Nafitlaan
Richard Rogers
Hylobate
Colin deSousa
Aurelia De La Silla
Maciej Kontny
Ricky L Cain
Iggyflare
Garrett Renner
Michael Harris
Joshua Maly
Nigel Guest
Theo Hodges
BERTHEAS Frédéric
lilMoni
Δημήτρης Μάρκογιαννακης
Lee S.
Chris Dibbs
jarrad tait
Jacen Solo
Hannes Rotestam
Preston Hicks
Лонгин
Will Fink
ControlFreq
IllAngel
John Giardina
Thiago Prado
Zhang Dijon
NoBurny
thibault tersinet
scarletsky
Nich Smith
Omegus
Karl Abrahamsson
Sara Fernandes
peetey897
Cooper Janse
G F
Glen Aultman-Bettridge
Nathan Rogers
Benjamin Mock
CadmiumMan
Kirk Edwards
Leigh G
Thom Colyer
Frederik
C pstj
Zachary Pecora
Trevor D'Arcey
Ryan Gauvin
Shawn Moore
Jim Channon
Kyarou
Actual_Dio
Jim B Johnson
Robert Rinne
Zion
Kenji Yamada
DerWolf
RLKZ1022
Neyimadd
Aaron Blair
Mira Cyr
Bird Law Expert
S A Rudy
Sam Spoerle
angel carrillo
Alden Z
Holly Loveless
Sea Wolf
Atenfox
Cyberate
E. Jason Davis
Caro Lyns
David Kaul
Charlotte Wiland
Kyle Barrett
Ian Grau-Fay
cameron cannon
RedSpaz
John Wick
Randy Ross
Rita
Ele
Johnathan Xavier Hutchinson
Andrew Stein
Ghettov Milan
Malke
TameMoon
Daniel Cooper
Markus Peham
The Next Level
Alexander Whittaker
Sidr Dim
William Markus
Jordan
Pleaseworkforonce
Damon Gallaty
Trentin Bergeron
Emarandzeb
Laulajatar
Dale McBane
Chris Kohut
Preston Mitchell
Andrew Kircher
Frank Fewkes
Moist mongol
Joshua Xiong
Jan Bundesmann
www15o
Game Master Pro
jason baldrick
Exp1nt
w
Shubham Jakhotiya
Braxton Istace
LesterThePossum
Rurikid
ojacid .
james
A Patreon of the Ahts
BigE0021
Angelique Badger
Jonathan Williams
AntiBlock
Redsauz
Florian Kelber
John Patrick Callahan Jr
Alexandra Vesey
Bas Kroot
Dzmitry Malyshau
PedanticSteve
Josh Wren
BLRageQuit
Dario Spadavecchia
Neutrix
Markell
Rocky
Robert Landry
Skylar Mangum-Turner
Nick Mowry
Anjen Pai
Hope You're Well
Alexandre Boivin
Racussa
Mike Conley
Karen Blythe
Mark Sprietsma
Xavier privé
Tommy Mayfield
Václav Švec
Binks
Mackenzie
Linn Browning
Writer's Consultant Page by George J.Lekkas
Andrew Hines
Wexxler
Jason Matthew Wuerfel
Milo Cohen
Alan Buehne
Dominick Ormsby
Espen Sæverud
Rasmus Legêne
rbbalderama
Nobody679
Prince of Morgoth
Jaryd Armstrong
Gary Smith
ThyHolyDevil
良义
Andrew Pirkola
Dig
Chris Gray
Tyshaun Wise
Phoenix
Ethan Cook
Jordan Bellah
Petro Lombaard
Kass Frisson
Lazer Elf
Gavin Madrigal
Rox
PinkEvil
Martin Lorber
Emanuel Pietri
Alex Beard
Jeffrey Henning
Eric Alexander Cartaya
Dust Bunny
GameNight
Beingus
Crys Cain
Lon Varnadore
Thomas Mortensen Hansen
Drinarius
Ed Wright
Adrian Wright
Zklaus
Chris Bloom
PlayByMail.Net
Maxim Lowe
Aquelion
Tiber
Daydream1013
Page One Project
Clonetone
Egoensis
Brad Wardell
Heaven N Lee
BarnabyJones
Paul Ingram
Lance Saba
Chad Riley
Austin
Rowland Kingman
Decimus Vitalis
Grayson McClead
Battleturtle1
Kristin Chernoff
Justin Mcclain
Patrick Jones
Esther Busch
Chance Mena
JimmyTheBob
Antiroo
Dalton Clark
Guilherme Aguiar
Simon Drapeau
Akirsop
Radovan Zapletal
Vanessa Anjos
Rikard Wolff
Justa Badge
teco 47
Jake
Miguel Alejandro
Blargh Blarghmoomoo
Jakob Siegel
Grant A. Murray
Jarno Hallikainen
Jan Ka
Joshua Vaught
MaxOliver
WarWizardGames
Evan-DiLeo
Eric Moore
Kyle S
Alex Debus
Uniquenameosaurus
Dean Dunakin
Jack
Bryan Brake
McNeil Atticus Inksmudge
Char
Tom Van Orden jr
Kendall Patterson
Akylos
Barna Csíkos
Nicholas Grabstas
OldFarkas
Riley Seaman
Daniel Gill
Kyle Robertson
Natasha Taylor
Pierrick Bertrand
Jared.K
Dylan Devenny
logic_error
SashaTK
Steve Johnson
MontyBoosh
Achillain
Jaden
Vito Martono
Thirty-OneR
Eric Foley
ThatGuyGW
Dee Chiu
James H. Anthony
Kevin Cossutta
MadNomadMedia
Darinius Dragonclaw Studios
Tsahyla (Triston Lightyear)
Christopher Whitney
María Martín López
Annie Rishor
Aram Sabatés
Jeppe Skov Jensen
Martin Seeger
Oneiris (Oni)
EternalDeiwos
Richard Keating
StroboWolf
Rick Falkvinge
Zewen Senpai
Adam Butler
Kassidy
Sadie Blackthorne
ErrorForever
Seth Fusion
Gus
Paul
Lucid
Allen Varney
Hannah May
Sankroh
Eliot Miller
Detocroix
Meg Ziegler
rob bee
Anoplexian
Marten F
Erin D. Smale
Johnpaul Morrow
Roekai
Drunken_Legends
Jesse Holmes
Maxwell Hill
Jan Dvořák
SirTobit
G0atfather
Allen S. Rout
Pippa Mitchell
Austin Miller
Caner Oleas Pekgönenç
Alison Bull Bear
Bradley Edwards
Tertiary
Daniel
Joshua E Goodwin
Shaun Alexander
Ryan Lege
Myrrhlin
Jesper Cockx
Noirbard
Dice
Brian Drennen
Giant Monster Games
Reya C.
Krk
Endwords
Jacob Harrington
RK
Michael Greiner
Steven Bennett
Brice Moss
Whakomatic x
Stephen Herron
kosmobius
ZizRenanim
Barished
Maur Razimtheth
Aaron bateson
Diklyquill
Shawn Taylor
Brady R Rathbun
FlippantFeline
Shadow
J
Tamashi Toh
Huw Williams
Graves
ShadeByTheSea
The Dungeon Masters
Valerie Elise
Empi3
William Pucs
Michael Carmody
Marco Veldman
naikibens220
Jordon Phillips
_gfx_
F. Casanova
Jared McDaris
BlastWind
Taldonix
Connor McMartin
Nexoness
Guy
Maggie
AdvancedAzrielAngel
Alfred García
Norbert Žigmund
Jennifer
Titanium Tomes
John Ackley
Invad3r233
Jonathan Killstring
Jessica Thomas
Nikita Kondratjuks
Steve Hyatt
PoliticsBuff
Ian arless
Karnat
Hilton Williams
Kevin
Katharina Haase
Hisham Bedri
Bird
JOSHUA QUALTIERI
Preston Brooks
Troy Schuler
DerGeisterbär
L. V. Werneck
Marcus Hellyrr
yami
Daniel Eric Crosby
Augusto Chiarle
Doug Churchman
David Roza
Alexander Thomas
Ashley Wilson-Savoury
Nathan L Myers
Theresa Walsh
JP Roberts III
William Henry
OldbeanOldboy
Javasharp
Diagonath
Gun Metal Games
Scott Marner
Alloyed Clavicle
Valerii Matskevych
Spencer Sherman
Nolan Moore
James Schellenger
Pat
Dino Princip
Shawn Spencer
Timothée CALLET
KC138
Nylian
Kate
Markus Finster
CanadianGold
AstralJacks
Keith Marshall
Scott Davis
Joseph Miranda
Shaptarshi Joarder
Branndon
EP
Johan Fröberg
Sasquatch
Chase Mayers
Sizz_TV
Ryan Westcott
Nathan Mitchell
Curt Flood
Mikey
E.M. White
Billy
Vlad Tomash
Xariun
Luke Nelson
W Maxwell Cassity-Guilliom
Marty H
Aaron Meyer
Max Amillios
chris
cyninge
Omegavoid
Fritjof Olsson
Crazypedia
Duncan Thomson
William Merriott
Gold Tamarin
Lhoris
Jonathan
Jon
Massimo Vella
Feuver
aymeric
Eric Schumann
Rei
Fondue
Paavi1
Wil Sisney
David Patterson
L
Justin Scheffers
Commieboo
Garrison Wood
Emsiron
Frosty
John Joseph Adams
The_Lone_Wanderer
Andrew Stein
Groonfish
soup
Bruno Haack Vilar
Ian Burke
Tentacle Shogun
Andrew Chandler
Fritz Wulfram
Doom Chupacabra
Zakharov
Dylan Fox
Alfred Piccioni
Avery Vreeland
Kennedy
Zack Wolf
Matjam
Jeff Johnston
Hunter Hawthorne
Sunsette
Travis Love
Dakian Delomast
Kyle
Davis Walker
Naomi
Clément D
Jake Herr
ReV0LT
Jack Dawson
Queso y Libertad
RadioJay21H
NEO
Crecs
A AASD
Mikhail Ushakov
NoFun
AmbiguousCake
Madeline Naiman
2FingerzDown
Josiah Lulf
Vector Tragedy
yann
Blarghle Hargle
Jelke Boonstra
afistupmabum
Rob Frantz
Driver
Tr4v3l3r
Cooper Cantrell
Maximilien Bouillot
J.E. Ellis
Igor
John Todd
burning.rosary
Shane Roppel
Hank Agrippa
Noah Morris
Phil Karecki
Matthew Jarocki
Lucius Licinius Lucullus
Andrew Haney
Noah Morris
Phil Karecki
Matthew Jarocki
Lucius Licinius Lucullus
Andrew Haney
Jesse Luke
Lord_Luce
Neko no Maigo
Hossyboy
Yasui Masatake
Jesse Roy
Remain
Douglas Rector
J Clark
Raine Logan
Matty Ice
DieMuetze
Dan Popoli
Marwyn
Kederalia
Whyse Wytch
Elliyevee
James Miller
Pirate Fish
David Leitner
Vyritecht
emre
Don't mail me
Isaac Wooten
MisterPete
Johanna Martin
Marmalade_MacGuffin
James Benware
FortunesFaded
breadsticks
Murderbits
Ben Jones
Marco Faltracco
L
silentArtifact
Keith Potter
Morgan Gilbert
Alengork Gamer
Don Johnson
Mish
effervescent
Trevor Sansom
Eric Christian Berg
Jamin
Aurelia De La Sella
morgan
Barry Gill
Wolf
Titan_650
Sebastian Tampu
dr_not_sam
Mie96
Riley
Amber Davis
tomtom1969vlbg`;