refactor: dynamically load modules

This commit is contained in:
Azgaar 2022-07-08 22:01:11 +03:00
parent a107c58643
commit 347083291f
46 changed files with 161 additions and 164 deletions

View file

@ -1,921 +0,0 @@
import * as d3 from "d3";
import {restoreDefaultEvents} from "scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "scripts/tooltips";
import {byId} from "utils/shorthands";
import {rn} from "utils/numberUtils";
import {capitalize} from "utils/stringUtils";
import {si} from "utils/unitUtils";
import {abbreviate} from "utils/languageUtils";
import {debounce} from "utils/functionUtils";
const $body = insertEditorHtml();
addListeners();
const cultureTypes = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
export function open() {
closeDialogs("#culturesEditor, .stable");
if (!layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleReligions")) toggleReligions();
if (layerIsOn("toggleProvinces")) toggleProvinces();
refreshCulturesEditor();
$("#culturesEditor").dialog({
title: "Cultures Editor",
resizable: false,
close: closeCulturesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
$body.focus();
}
function insertEditorHtml() {
const editorHtml = /* html */ `<div id="culturesEditor" class="dialog stable">
<div id="culturesHeader" class="header" style="grid-template-columns: 10em 7em 8em 4em 8em 5em 8em 8em">
<div data-tip="Click to sort by culture name" class="sortable alphabetically" data-sortby="name">Culture&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">
<label data-tip="Change brush size" data-shortcut="+ (increase), (decrease)" class="italic">Brush size:
<input
id="culturesManuallyBrush"
type="range"
min="5"
max="99"
value="15"
style="width: 7em"
/>
<input
id="culturesManuallyBrushNumber"
type="number"
min="5"
max="99"
value="15"
/> </label><br />
<button id="culturesManuallyApply" data-tip="Apply assignment" class="icon-check"></button>
<button id="culturesManuallyCancel" data-tip="Cancel assignment" class="icon-cancel"></button>
</div>
<button id="culturesEditNamesBase" data-tip="Edit a database used for names generation" class="icon-font"></button>
<button id="culturesAdd" data-tip="Add a new culture. Hold Shift to add multiple" class="icon-plus"></button>
<button id="culturesExport" data-tip="Download cultures-related data" class="icon-download"></button>
<button id="culturesImport" data-tip="Upload cultures-related data" class="icon-upload"></button>
<button id="culturesRecalculate" data-tip="Recalculate cultures based on current values of growth-related attributes" class="icon-retweet"></button>
<span data-tip="Allow culture centers, expansion and type changes to take an immediate effect">
<input id="culturesAutoChange" class="checkbox" type="checkbox" />
<label for="culturesAutoChange" class="checkbox-label"><i>auto-apply changes</i></label>
</span>
</div>
</div>`;
byId("dialogs").insertAdjacentHTML("beforeend", editorHtml);
return byId("culturesBody");
}
function addListeners() {
applySortingByHeader("culturesHeader");
byId("culturesEditorRefresh").on("click", refreshCulturesEditor);
byId("culturesEditStyle").on("click", () => editStyle("cults"));
byId("culturesLegend").on("click", toggleLegend);
byId("culturesPercentage").on("click", togglePercentageMode);
byId("culturesHeirarchy").on("click", showHierarchy);
byId("culturesRecalculate").on("click", () => recalculateCultures(true));
byId("culturesManually").on("click", enterCultureManualAssignent);
byId("culturesManuallyApply").on("click", applyCultureManualAssignent);
byId("culturesManuallyCancel").on("click", () => exitCulturesManualAssignment());
byId("culturesEditNamesBase").on("click", editNamesbase);
byId("culturesAdd").on("click", enterAddCulturesMode);
byId("culturesExport").on("click", downloadCulturesCsv);
byId("culturesImport").on("click", () => byId("culturesCSVToLoad").click());
byId("culturesCSVToLoad").on("change", uploadCulturesData);
}
function refreshCulturesEditor() {
culturesCollectStatistics();
culturesEditorAddLines();
drawCultureCenters();
}
function culturesCollectStatistics() {
const {cells, cultures, burgs} = pack;
cultures.forEach(c => {
c.cells = c.area = c.rural = c.urban = 0;
});
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const cultureId = cells.culture[i];
cultures[cultureId].cells += 1;
cultures[cultureId].area += cells.area[i];
cultures[cultureId].rural += cells.pop[i];
const burgId = cells.burg[i];
if (burgId) cultures[cultureId].urban += burgs[burgId].population;
}
}
function culturesEditorAddLines() {
const unit = getAreaUnit();
let lines = "";
let totalArea = 0;
let totalPopulation = 0;
const emblemShapeGroup = byId("emblemShape")?.selectedOptions[0]?.parentNode?.label;
const selectShape = emblemShapeGroup === "Diversiform";
for (const c of pack.cultures) {
if (c.removed) continue;
const area = getArea(c.area);
const rural = c.rural * populationRate;
const urban = c.urban * populationRate * urbanization;
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}. Rural population: ${si(rural)}. Urban population: ${si(
urban
)}. Click to edit`;
totalArea += area;
totalPopulation += population;
if (!c.i) {
// Uncultured (neutral) line
lines += /* html */ `<div
class="states"
data-id="${c.i}"
data-name="${c.name}"
data-color=""
data-cells="${c.cells}"
data-area="${area}"
data-population="${population}"
data-base="${c.base}"
data-type=""
data-expansionism=""
data-emblems="${c.shield}"
>
<svg width="11" height="11" class="placeholder"></svg>
<input data-tip="Neutral culture name. Click and type to change" class="cultureName italic" style="width: 7em"
value="${c.name}" autocorrect="off" spellcheck="false" />
<span class="icon-cw placeholder"></span>
<select class="cultureType placeholder">${getTypeOptions(c.type)}</select>
<select data-tip="Culture namesbase. Click to change. Click on arrows to re-generate names"
class="cultureBase">${getBaseOptions(c.base)}</select>
<span data-tip="Cells count" class="icon-check-empty hide"></span>
<div data-tip="Cells count" class="cultureCells hide" style="width: 4em">${c.cells}</div>
<span class="icon-resize-full placeholder hide"></span>
<input class="cultureExpan placeholder hide" type="number" />
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Culture area" class="cultureArea hide" style="width: 6em">${si(area)} ${unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="culturePopulation hide pointer"
style="width: 5em">${si(population)}</div>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
${getShapeOptions(selectShape, c.shield)}
</div>`;
continue;
}
lines += /* html */ `<div
class="states"
data-id="${c.i}"
data-name="${c.name}"
data-color="${c.color}"
data-cells="${c.cells}"
data-area="${area}"
data-population="${population}"
data-base="${c.base}"
data-type="${c.type}"
data-expansionism="${c.expansionism}"
data-emblems="${c.shield}"
>
<fill-box fill="${c.color}"></fill-box>
<input data-tip="Culture name. Click and type to change" class="cultureName" style="width: 7em"
value="${c.name}" autocorrect="off" spellcheck="false" />
<span data-tip="Regenerate culture name" class="icon-cw hiddenIcon" style="visibility: hidden"></span>
<select data-tip="Culture type. Defines growth model. Click to change"
class="cultureType">${getTypeOptions(c.type)}</select>
<select data-tip="Culture namesbase. Click to change. Click on arrows to re-generate names"
class="cultureBase">${getBaseOptions(c.base)}</select>
<span data-tip="Cells count" class="icon-check-empty hide"></span>
<div data-tip="Cells count" class="cultureCells hide" style="width: 4em">${c.cells}</div>
<span data-tip="Culture expansionism. Defines competitive size" class="icon-resize-full hide"></span>
<input
data-tip="Culture expansionism. Defines competitive size. Click to change, then click Recalculate to apply change"
class="cultureExpan hide"
type="number"
min="0"
max="99"
step=".1"
value=${c.expansionism}
/>
<span data-tip="Culture area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Culture area" class="cultureArea hide" style="width: 6em">${si(area)} ${unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="culturePopulation hide pointer"
style="width: 5em">${si(population)}</div>
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
${getShapeOptions(selectShape, c.shield)}
<span data-tip="Remove culture" class="icon-trash-empty hide"></span>
</div>`;
}
$body.innerHTML = lines;
// update footer
byId("culturesFooterCultures").innerHTML = pack.cultures.filter(c => c.i && !c.removed).length;
byId("culturesFooterCells").innerHTML = pack.cells.h.filter(h => h >= 20).length;
byId("culturesFooterArea").innerHTML = `${si(totalArea)} ${unit}`;
byId("culturesFooterPopulation").innerHTML = si(totalPopulation);
byId("culturesFooterArea").dataset.area = totalArea;
byId("culturesFooterPopulation").dataset.population = totalPopulation;
// add listeners
$body.querySelectorAll(":scope > div").forEach($line => {
$line.on("mouseenter", cultureHighlightOn);
$line.on("mouseleave", cultureHighlightOff);
$line.on("click", selectCultureOnLineClick);
});
$body.querySelectorAll("fill-box").forEach($el => $el.on("click", cultureChangeColor));
$body.querySelectorAll("div > input.cultureName").forEach($el => $el.on("input", cultureChangeName));
$body.querySelectorAll("div > span.icon-cw").forEach($el => $el.on("click", cultureRegenerateName));
$body.querySelectorAll("div > input.cultureExpan").forEach($el => $el.on("input", cultureChangeExpansionism));
$body.querySelectorAll("div > select.cultureType").forEach($el => $el.on("change", cultureChangeType));
$body.querySelectorAll("div > select.cultureBase").forEach($el => $el.on("change", cultureChangeBase));
$body.querySelectorAll("div > select.cultureEmblems").forEach($el => $el.on("change", cultureChangeEmblemsShape));
$body.querySelectorAll("div > div.culturePopulation").forEach($el => $el.on("click", changePopulation));
$body.querySelectorAll("div > span.icon-arrows-cw").forEach($el => $el.on("click", cultureRegenerateBurgs));
$body.querySelectorAll("div > span.icon-trash-empty").forEach($el => $el.on("click", cultureRemovePrompt));
const $culturesHeader = byId("culturesHeader");
$culturesHeader.querySelector("div[data-sortby='emblems']").style.display = selectShape ? "inline-block" : "none";
if ($body.dataset.type === "percentage") {
$body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting($culturesHeader);
$("#culturesEditor").dialog({width: "fit-content"});
}
function getTypeOptions(type) {
let options = "";
cultureTypes.forEach(t => (options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`));
return options;
}
function getBaseOptions(base) {
let options = "";
nameBases.forEach((n, i) => (options += `<option ${base === i ? "selected" : ""} value="${i}">${n.name}</option>`));
return options;
}
function getShapeOptions(selectShape, selected) {
if (!selectShape) return "";
const shapes = Object.keys(COA.shields.types)
.map(type => Object.keys(COA.shields[type]))
.flat();
const options = shapes.map(
shape => `<option ${shape === selected ? "selected" : ""} value="${shape}">${capitalize(shape)}</option>`
);
return `<select data-tip="Emblem shape associated with culture. Click to change" class="cultureEmblems hide">${options}</select>`;
}
const cultureHighlightOn = debounce(event => {
const cultureId = Number(event.id || event.target.dataset.id);
if (!layerIsOn("toggleCultures")) return;
if (customization) return;
const animate = d3.transition().duration(2000).ease(d3.easeSinIn);
cults
.select("#culture" + cultureId)
.raise()
.transition(animate)
.attr("stroke-width", 2.5)
.attr("stroke", "#d0240f");
debug
.select("#cultureCenter" + cultureId)
.raise()
.transition(animate)
.attr("r", 8)
.attr("stroke", "#d0240f");
}, 200);
function cultureHighlightOff(event) {
const cultureId = Number(event.id || event.target.dataset.id);
if (!layerIsOn("toggleCultures")) return;
cults
.select("#culture" + cultureId)
.transition()
.attr("stroke-width", null)
.attr("stroke", null);
debug
.select("#cultureCenter" + cultureId)
.transition()
.attr("r", 6)
.attr("stroke", null);
}
function cultureChangeColor() {
const $el = this;
const currentFill = $el.getAttribute("fill");
const cultureId = +$el.parentNode.dataset.id;
const callback = newFill => {
$el.fill = newFill;
pack.cultures[cultureId].color = newFill;
cults
.select("#culture" + cultureId)
.attr("fill", newFill)
.attr("stroke", newFill);
debug.select("#cultureCenter" + cultureId).attr("fill", newFill);
};
openPicker(currentFill, callback);
}
function cultureChangeName() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.name = this.value;
pack.cultures[culture].name = this.value;
pack.cultures[culture].code = abbreviate(
this.value,
pack.cultures.map(c => c.code)
);
}
function cultureRegenerateName() {
const culture = +this.parentNode.dataset.id;
const name = Names.getCultureShort(culture);
this.parentNode.querySelector("input.cultureName").value = name;
pack.cultures[culture].name = name;
}
function cultureChangeExpansionism() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.expansionism = this.value;
pack.cultures[culture].expansionism = +this.value;
recalculateCultures();
}
function cultureChangeType() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.type = this.value;
pack.cultures[culture].type = this.value;
recalculateCultures();
}
function cultureChangeBase() {
const culture = +this.parentNode.dataset.id;
const v = +this.value;
this.parentNode.dataset.base = pack.cultures[culture].base = v;
}
function cultureChangeEmblemsShape() {
const culture = +this.parentNode.dataset.id;
const shape = this.value;
this.parentNode.dataset.emblems = pack.cultures[culture].shield = shape;
const rerenderCOA = (id, coa) => {
const $coa = byId(id);
if (!$coa) return; // not rendered
$coa.remove();
COArenderer.trigger(id, coa);
};
pack.states.forEach(state => {
if (state.culture !== culture || !state.i || state.removed || !state.coa || state.coa === "custom") return;
if (shape === state.coa.shield) return;
state.coa.shield = shape;
rerenderCOA("stateCOA" + state.i, state.coa);
});
pack.provinces.forEach(province => {
if (
pack.cells.culture[province.center] !== culture ||
!province.i ||
province.removed ||
!province.coa ||
province.coa === "custom"
)
return;
if (shape === province.coa.shield) return;
province.coa.shield = shape;
rerenderCOA("provinceCOA" + province.i, province.coa);
});
pack.burgs.forEach(burg => {
if (burg.culture !== culture || !burg.i || burg.removed || !burg.coa || burg.coa === "custom") return;
if (shape === burg.coa.shield) return;
burg.coa.shield = shape;
rerenderCOA("burgCOA" + burg.i, burg.coa);
});
}
function changePopulation() {
const cultureId = +this.parentNode.dataset.id;
const culture = pack.cultures[cultureId];
if (!culture.cells) return tip("Culture does not have any cells, cannot change population", false, "error");
const rural = rn(culture.rural * populationRate);
const urban = rn(culture.urban * populationRate * urbanization);
const total = rural + urban;
const format = n => Number(n).toLocaleString();
const burgs = pack.burgs.filter(b => !b.removed && b.culture === cultureId);
alertMessage.innerHTML = /* html */ `<div>
<i>Change population of all cells assigned to the culture</i>
<div style="margin: 0.5em 0">
Rural: <input type="number" min="0" step="1" id="ruralPop" value=${rural} style="width:6em" />
Urban: <input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em"
${burgs.length ? "" : "disabled"} />
</div>
<div>Total population: ${format(total)} <span id="totalPop">${format(total)}</span>
(<span id="totalPopPerc">100</span>%)
</div>
</div>`;
const update = function () {
const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber;
if (isNaN(totalNew)) return;
totalPop.innerHTML = l(totalNew);
totalPopPerc.innerHTML = rn((totalNew / total) * 100);
};
ruralPop.oninput = () => update();
urbanPop.oninput = () => update();
$("#alert").dialog({
resizable: false,
title: "Change culture population",
width: "24em",
buttons: {
Apply: function () {
applyPopulationChange(rural, urban, ruralPop.value, urbanPop.value, cultureId);
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
function applyPopulationChange(oldRural, oldUrban, newRural, newUrban, culture) {
const ruralChange = newRural / oldRural;
if (isFinite(ruralChange) && ruralChange !== 1) {
const cells = pack.cells.i.filter(i => pack.cells.culture[i] === culture);
cells.forEach(i => (pack.cells.pop[i] *= ruralChange));
}
if (!isFinite(ruralChange) && +newRural > 0) {
const points = newRural / populationRate;
const cells = pack.cells.i.filter(i => pack.cells.culture[i] === culture);
const pop = rn(points / cells.length);
cells.forEach(i => (pack.cells.pop[i] = pop));
}
const burgs = pack.burgs.filter(b => !b.removed && b.culture === culture);
const urbanChange = newUrban / oldUrban;
if (isFinite(urbanChange) && urbanChange !== 1) {
burgs.forEach(b => (b.population = rn(b.population * urbanChange, 4)));
}
if (!isFinite(urbanChange) && +newUrban > 0) {
const points = newUrban / populationRate / urbanization;
const population = rn(points / burgs.length, 4);
burgs.forEach(b => (b.population = population));
}
refreshCulturesEditor();
}
function cultureRegenerateBurgs() {
if (customization === 4) return;
const cultureId = +this.parentNode.dataset.id;
const cBurgs = pack.burgs.filter(b => b.culture === cultureId && !b.lock);
cBurgs.forEach(b => {
b.name = Names.getCulture(cultureId);
labels.select("[data-id='" + b.i + "']").text(b.name);
});
tip(`Names for ${cBurgs.length} burgs are regenerated`, false, "success");
}
function removeCulture(cultureId) {
cults.select("#culture" + cultureId).remove();
debug.select("#cultureCenter" + cultureId).remove();
const {burgs, states, cells, cultures} = pack;
burgs.filter(b => b.culture == cultureId).forEach(b => (b.culture = 0));
states.forEach(s => {
if (s.culture === cultureId) s.culture = 0;
});
cells.culture.forEach((c, i) => {
if (c === cultureId) cells.culture[i] = 0;
});
cultures[cultureId].removed = true;
cultures
.filter(c => c.i && !c.removed)
.forEach(c => {
c.origins = c.origins.filter(origin => origin !== cultureId);
if (!c.origins.length) c.origins = [0];
});
refreshCulturesEditor();
}
function cultureRemovePrompt() {
if (customization) return;
const cultureId = +this.parentNode.dataset.id;
confirmationDialog({
title: "Remove culture",
message: "Are you sure you want to remove the culture? <br>This action cannot be reverted",
confirm: "Remove",
onConfirm: () => removeCulture(cultureId)
});
}
function drawCultureCenters() {
const tooltip = "Drag to move the culture center (ancestral home)";
debug.select("#cultureCenters").remove();
const cultureCenters = debug
.append("g")
.attr("id", "cultureCenters")
.attr("stroke-width", 2)
.attr("stroke", "#444444")
.style("cursor", "move");
const data = pack.cultures.filter(c => c.i && !c.removed);
cultureCenters
.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("id", d => "cultureCenter" + d.i)
.attr("data-id", d => d.i)
.attr("r", 6)
.attr("fill", d => d.color)
.attr("cx", d => pack.cells.p[d.center][0])
.attr("cy", d => pack.cells.p[d.center][1])
.on("mouseenter", d => {
tip(tooltip, true);
$body.querySelector(`div[data-id='${d.i}']`).classList.add("selected");
cultureHighlightOn(event);
})
.on("mouseleave", d => {
tip("", true);
$body.querySelector(`div[data-id='${d.i}']`).classList.remove("selected");
cultureHighlightOff(event);
})
.call(d3.drag().on("start", cultureCenterDrag));
}
function cultureCenterDrag() {
const $el = d3.select(this);
const cultureId = +this.id.slice(13);
d3.event.on("drag", () => {
const {x, y} = d3.event;
$el.attr("cx", x).attr("cy", y);
const cell = findCell(x, y);
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
pack.cultures[cultureId].center = cell;
recalculateCultures();
});
}
function toggleLegend() {
if (legend.selectAll("*").size()) return clearLegend();
const data = pack.cultures
.filter(c => c.i && !c.removed && c.cells)
.sort((a, b) => b.area - a.area)
.map(c => [c.i, c.color, c.name]);
drawLegend("Cultures", data);
}
function togglePercentageMode() {
if ($body.dataset.type === "absolute") {
$body.dataset.type = "percentage";
const totalCells = +byId("culturesFooterCells").innerText;
const totalArea = +byId("culturesFooterArea").dataset.area;
const totalPopulation = +byId("culturesFooterPopulation").dataset.population;
$body.querySelectorAll(":scope > div").forEach(function (el) {
const {cells, area, population} = el.dataset;
el.querySelector(".cultureCells").innerText = rn((+cells / totalCells) * 100) + "%";
el.querySelector(".cultureArea").innerText = rn((+area / totalArea) * 100) + "%";
el.querySelector(".culturePopulation").innerText = rn((+population / totalPopulation) * 100) + "%";
});
} else {
$body.dataset.type = "absolute";
culturesEditorAddLines();
}
}
async function showHierarchy() {
if (customization) return;
const HeirarchyTree = await import("../hierarchy-tree.js");
const getDescription = culture => {
const {name, type, rural, urban} = culture;
const population = rural * populationRate + urban * populationRate * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name} culture. ${type}. ${populationText}`;
};
const getShape = ({type}) => {
if (type === "Generic") return "circle";
if (type === "River") return "diamond";
if (type === "Lake") return "hexagon";
if (type === "Naval") return "square";
if (type === "Highland") return "concave";
if (type === "Nomadic") return "octagon";
if (type === "Hunting") return "pentagon";
};
HeirarchyTree.open({
type: "cultures",
data: pack.cultures,
onNodeEnter: cultureHighlightOn,
onNodeLeave: cultureHighlightOff,
getDescription,
getShape
});
}
function recalculateCultures(must) {
if (!must && !culturesAutoChange.checked) return;
pack.cells.culture = new Uint16Array(pack.cells.i.length);
pack.cultures.forEach(function (c) {
if (!c.i || c.removed) return;
pack.cells.culture[c.center] = c.i;
});
Cultures.expand();
drawCultures();
pack.burgs.forEach(b => (b.culture = pack.cells.culture[b.cell]));
refreshCulturesEditor();
document.querySelector("input.cultureExpan").focus(); // to not trigger hotkeys
}
function enterCultureManualAssignent() {
if (!layerIsOn("toggleCultures")) toggleCultures();
customization = 4;
cults.append("g").attr("id", "temp");
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "none"));
byId("culturesManuallyButtons").style.display = "inline-block";
debug.select("#cultureCenters").style("display", "none");
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
culturesFooter.style.display = "none";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
$("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
tip("Click on culture to select, drag the circle to change culture", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectCultureOnMapClick)
.call(d3.drag().on("start", dragCultureBrush))
.on("touchmove mousemove", moveCultureBrush);
$body.querySelector("div").classList.add("selected");
}
function selectCultureOnLineClick(i) {
if (customization !== 4) return;
$body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectCultureOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = cults.select("#temp").select("polygon[data-cell='" + i + "']");
const culture = assigned.size() ? +assigned.attr("data-culture") : pack.cells.culture[i];
$body.querySelector("div.selected").classList.remove("selected");
$body.querySelector("div[data-id='" + culture + "']").classList.add("selected");
}
function dragCultureBrush() {
const radius = +culturesManuallyBrush.value;
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const p = d3.mouse(this);
moveCircle(p[0], p[1], radius);
const found = radius > 5 ? findAll(p[0], p[1], radius) : [findCell(p[0], p[1], radius)];
const selection = found.filter(isLand);
if (selection) changeCultureForSelection(selection);
});
}
function changeCultureForSelection(selection) {
const temp = cults.select("#temp");
const selected = $body.querySelector("div.selected");
const cultureNew = +selected.dataset.id;
const color = pack.cultures[cultureNew].color || "#ffffff";
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const cultureOld = exists.size() ? +exists.attr("data-culture") : pack.cells.culture[i];
if (cultureNew === cultureOld) return;
// change of append new element
if (exists.size()) exists.attr("data-culture", cultureNew).attr("fill", color).attr("stroke", color);
else
temp
.append("polygon")
.attr("data-cell", i)
.attr("data-culture", cultureNew)
.attr("points", getPackPolygon(i))
.attr("fill", color)
.attr("stroke", color);
});
}
function moveCultureBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +culturesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyCultureManualAssignent() {
const changed = cults.select("#temp").selectAll("polygon");
changed.each(function () {
const i = +this.dataset.cell;
const c = +this.dataset.culture;
pack.cells.culture[i] = c;
if (pack.cells.burg[i]) pack.burgs[pack.cells.burg[i]].culture = c;
});
if (changed.size()) {
drawCultures();
refreshCulturesEditor();
}
exitCulturesManualAssignment();
}
function exitCulturesManualAssignment(close) {
customization = 0;
cults.select("#temp").remove();
removeCircle();
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "inline-block"));
byId("culturesManuallyButtons").style.display = "none";
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.remove("hidden"));
culturesFooter.style.display = "block";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close) $("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
debug.select("#cultureCenters").style("display", null);
restoreDefaultEvents();
clearMainTip();
const selected = $body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
}
function enterAddCulturesMode() {
if (this.classList.contains("pressed")) return exitAddCultureMode();
customization = 9;
this.classList.add("pressed");
tip("Click on the map to add a new culture", true);
viewbox.style("cursor", "crosshair").on("click", addCulture);
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
}
function exitAddCultureMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (culturesAdd.classList.contains("pressed")) culturesAdd.classList.remove("pressed");
}
function addCulture() {
const point = d3.mouse(this);
const center = findCell(point[0], point[1]);
if (pack.cells.h[center] < 20)
return tip("You cannot place culture center into the water. Please click on a land cell", false, "error");
const occupied = pack.cultures.some(c => !c.removed && c.center === center);
if (occupied) return tip("This cell is already a culture center. Please select a different cell", false, "error");
if (d3.event.shiftKey === false) exitAddCultureMode();
Cultures.add(center);
drawCultureCenters();
culturesEditorAddLines();
}
function downloadCulturesCsv() {
const unit = getAreaUnit("2");
const headers = `Id,Name,Color,Cells,Expansionism,Type,Area ${unit},Population,Namesbase,Emblems Shape,Origins`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, color, cells, expansionism, type, area, population, emblems, base} = $line.dataset;
const namesbase = nameBases[+base].name;
const {origins} = pack.cultures[+id];
const originList = origins.filter(origin => origin).map(origin => pack.cultures[origin].name);
const originText = '"' + originList.join(", ") + '"';
return [id, name, color, cells, expansionism, type, area, population, namesbase, emblems, originText].join(",");
});
const csvData = [headers].concat(data).join("\n");
const name = getFileName("Cultures") + ".csv";
downloadFile(csvData, name);
}
function closeCulturesEditor() {
debug.select("#cultureCenters").remove();
exitCulturesManualAssignment("close");
exitAddCultureMode();
}
async function uploadCulturesData() {
const csv = await Formats.csvParser(this.files[0]);
this.value = "";
const {cultures, cells} = pack;
const shapes = Object.keys(COA.shields.types)
.map(type => Object.keys(COA.shields[type]))
.flat();
const populated = cells.pop.map((c, i) => (c ? i : null)).filter(c => c);
cultures.forEach(item => {
if (item.i) item.removed = true;
});
for (const c of csv.iterator((a, b) => +a[0] > +b[0])) {
let current;
if (+c.id < cultures.length) {
current = cultures[c.id];
const ratio = current.urban / (current.rural + current.urban);
applyPopulationChange(current.rural, current.urban, c.population * (1 - ratio), c.population * ratio, +c.id);
} else {
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origin: 0, rural: 0, urban: 0};
cultures.push(current);
}
current.removed = false;
current.name = c.culture;
current.code = abbreviate(
current.name,
cultures.map(c => c.code)
);
current.color = c.color;
current.expansionism = +c.expansionism;
current.origins = JSON.parse(c.origins);
if (cultureTypes.includes(c.type)) current.type = c.type;
else current.type = "Generic";
const shieldShape = c["emblems shape"].toLowerCase();
if (shapes.includes(shieldShape)) current.shield = shieldShape;
else current.shield = "heater";
const nameBaseIndex = nameBases.findIndex(n => n.name == c.namesbase);
current.base = nameBaseIndex === -1 ? 0 : nameBaseIndex;
}
cultures.filter(c => c.removed).forEach(c => removeCulture(c.i));
drawCultures();
refreshCulturesEditor();
}

View file

@ -1,768 +0,0 @@
import * as d3 from "d3";
import {restoreDefaultEvents} from "scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "scripts/tooltips";
import {byId} from "utils/shorthands";
import {rn} from "utils/numberUtils";
import {si} from "utils/unitUtils";
import {abbreviate} from "utils/languageUtils";
import {debounce} from "utils/functionUtils";
const $body = insertEditorHtml();
addListeners();
export function open() {
closeDialogs("#religionsEditor, .stable");
if (!layerIsOn("toggleReligions")) toggleCultures();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleCultures")) toggleReligions();
if (layerIsOn("toggleProvinces")) toggleProvinces();
refreshReligionsEditor();
drawReligionCenters();
$("#religionsEditor").dialog({
title: "Religions Editor",
resizable: false,
close: closeReligionsEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
$body.focus();
}
function insertEditorHtml() {
const editorHtml = /* html */ `<div id="religionsEditor" class="dialog stable">
<div id="religionsHeader" class="header" style="grid-template-columns: 13em 6em 7em 18em 5em 6em">
<div data-tip="Click to sort by religion name" class="sortable alphabetically" data-sortby="name">Religion&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 hide" 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>
<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">
<label data-tip="Change brush size" data-shortcut="+ (increase), (decrease)" class="italic">Brush size:
<input
id="religionsManuallyBrush"
oninput="tip('Brush size: '+this.value); religionsManuallyBrushNumber.value = this.value"
type="range"
min="5"
max="99"
value="15"
style="width: 7em"
/>
<input
id="religionsManuallyBrushNumber"
oninput="tip('Brush size: '+this.value); religionsManuallyBrush.value = this.value"
type="number"
min="5"
max="99"
value="15"
/> </label
><br />
<button id="religionsManuallyApply" data-tip="Apply assignment" class="icon-check"></button>
<button id="religionsManuallyCancel" data-tip="Cancel assignment" class="icon-cancel"></button>
</div>
<button id="religionsAdd" data-tip="Add a new religion. Hold Shift to add multiple" class="icon-plus"></button>
<button id="religionsExport" data-tip="Download religions-related data" class="icon-download"></button>
</div>
</div>`;
byId("dialogs").insertAdjacentHTML("beforeend", editorHtml);
return byId("religionsBody");
}
function addListeners() {
applySortingByHeader("religionsHeader");
byId("religionsEditorRefresh").on("click", refreshReligionsEditor);
byId("religionsEditStyle").on("click", () => editStyle("relig"));
byId("religionsLegend").on("click", toggleLegend);
byId("religionsPercentage").on("click", togglePercentageMode);
byId("religionsHeirarchy").on("click", showHierarchy);
byId("religionsExtinct").on("click", toggleExtinct);
byId("religionsManually").on("click", enterReligionsManualAssignent);
byId("religionsManuallyApply").on("click", applyReligionsManualAssignent);
byId("religionsManuallyCancel").on("click", () => exitReligionsManualAssignment());
byId("religionsAdd").on("click", enterAddReligionMode);
byId("religionsExport").on("click", downloadReligionsCsv);
}
function refreshReligionsEditor() {
religionsCollectStatistics();
religionsEditorAddLines();
}
function religionsCollectStatistics() {
const {cells, religions, burgs} = pack;
religions.forEach(r => {
r.cells = r.area = r.rural = r.urban = 0;
});
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const religionId = cells.religion[i];
religions[religionId].cells += 1;
religions[religionId].area += cells.area[i];
religions[religionId].rural += cells.pop[i];
const burgId = cells.burg[i];
if (burgId) religions[religionId].urban += burgs[burgId].population;
}
}
// add line for each religion
function religionsEditorAddLines() {
const unit = " " + getAreaUnit();
let lines = "";
let totalArea = 0;
let totalPopulation = 0;
for (const r of pack.religions) {
if (r.removed) continue;
if (r.i && !r.cells && $body.dataset.extinct !== "show") continue; // hide extinct religions
const area = getArea(r.area);
const rural = r.rural * populationRate;
const urban = r.urban * populationRate * urbanization;
const population = rn(rural + urban);
const populationTip = `Believers: ${si(population)}; Rural areas: ${si(rural)}; Urban areas: ${si(
urban
)}. Click to change`;
totalArea += area;
totalPopulation += population;
if (!r.i) {
// No religion (neutral) line
lines += /* html */ `<div
class="states"
data-id="${r.i}"
data-name="${r.name}"
data-color=""
data-area="${area}"
data-population="${population}"
data-type=""
data-form=""
data-deity=""
data-expansionism=""
>
<svg width="11" height="11" class="placeholder"></svg>
<input data-tip="Religion name. Click and type to change" class="religionName italic" style="width: 11em"
value="${r.name}" autocorrect="off" spellcheck="false" />
<select data-tip="Religion type" class="religionType placeholder" style="width: 5em">
${getTypeOptions(r.type)}
</select>
<input data-tip="Religion form" class="religionForm placeholder hide" style="width: 6em" value="" autocorrect="off" spellcheck="false" />
<span data-tip="Click to re-generate supreme deity" class="icon-arrows-cw placeholder hide"></span>
<input data-tip="Religion supreme deity" class="religionDeity placeholder hide" style="width: 17em" value="" autocorrect="off" spellcheck="false" />
<span data-tip="Religion area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Religion area" class="religionArea hide" style="width: 5em">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="religionPopulation hide pointer">${si(population)}</div>
</div>`;
continue;
}
lines += /* html */ `<div
class="states"
data-id=${r.i}
data-name="${r.name}"
data-color="${r.color}"
data-area=${area}
data-population=${population}
data-type="${r.type}"
data-form="${r.form}"
data-deity="${r.deity || ""}"
data-expansionism="${r.expansionism}"
>
<fill-box fill="${r.color}"></fill-box>
<input data-tip="Religion name. Click and type to change" class="religionName" style="width: 11em"
value="${r.name}" autocorrect="off" spellcheck="false" />
<select data-tip="Religion type" class="religionType" style="width: 5em">
${getTypeOptions(r.type)}
</select>
<input data-tip="Religion form" class="religionForm hide" style="width: 6em"
value="${r.form}" autocorrect="off" spellcheck="false" />
<span data-tip="Click to re-generate supreme deity" class="icon-arrows-cw hide"></span>
<input data-tip="Religion supreme deity" class="religionDeity hide" style="width: 17em"
value="${r.deity || ""}" autocorrect="off" spellcheck="false" />
<span data-tip="Religion area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Religion area" class="religionArea hide" style="width: 5em">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="religionPopulation hide pointer">${si(population)}</div>
<span data-tip="Remove religion" class="icon-trash-empty hide"></span>
</div>`;
}
$body.innerHTML = lines;
// update footer
const validReligions = pack.religions.filter(r => r.i && !r.removed);
byId("religionsOrganized").innerHTML = validReligions.filter(r => r.type === "Organized").length;
byId("religionsHeresies").innerHTML = validReligions.filter(r => r.type === "Heresy").length;
byId("religionsCults").innerHTML = validReligions.filter(r => r.type === "Cult").length;
byId("religionsFolk").innerHTML = validReligions.filter(r => r.type === "Folk").length;
byId("religionsFooterArea").innerHTML = si(totalArea) + unit;
byId("religionsFooterPopulation").innerHTML = si(totalPopulation);
byId("religionsFooterArea").dataset.area = totalArea;
byId("religionsFooterPopulation").dataset.population = totalPopulation;
// add listeners
$body.querySelectorAll(":scope > div").forEach($line => {
$line.on("mouseenter", religionHighlightOn);
$line.on("mouseleave", religionHighlightOff);
$line.on("click", selectReligionOnLineClick);
});
$body.querySelectorAll("fill-box").forEach(el => el.on("click", religionChangeColor));
$body.querySelectorAll("div > input.religionName").forEach(el => el.on("input", religionChangeName));
$body.querySelectorAll("div > select.religionType").forEach(el => el.on("change", religionChangeType));
$body.querySelectorAll("div > input.religionForm").forEach(el => el.on("input", religionChangeForm));
$body.querySelectorAll("div > input.religionDeity").forEach(el => el.on("input", religionChangeDeity));
$body.querySelectorAll("div > span.icon-arrows-cw").forEach(el => el.on("click", regenerateDeity));
$body.querySelectorAll("div > div.religionPopulation").forEach(el => el.on("click", changePopulation));
$body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", religionRemovePrompt));
if ($body.dataset.type === "percentage") {
$body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(religionsHeader);
$("#religionsEditor").dialog({width: "fit-content"});
}
function getTypeOptions(type) {
let options = "";
const types = ["Folk", "Organized", "Cult", "Heresy"];
types.forEach(t => (options += `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`));
return options;
}
const religionHighlightOn = debounce(event => {
const religionId = Number(event.id || event.target.dataset.id);
const $el = $body.querySelector(`div[data-id='${religionId}']`);
if ($el) $el.classList.add("active");
if (!layerIsOn("toggleReligions")) return;
if (customization) return;
const animate = d3.transition().duration(1500).ease(d3.easeSinIn);
relig
.select("#religion" + religionId)
.raise()
.transition(animate)
.attr("stroke-width", 2.5)
.attr("stroke", "#c13119");
debug
.select("#religionsCenter" + religionId)
.raise()
.transition(animate)
.attr("r", 8)
.attr("stroke-width", 2)
.attr("stroke", "#c13119");
}, 200);
function religionHighlightOff(event) {
const religionId = Number(event.id || event.target.dataset.id);
const $el = $body.querySelector(`div[data-id='${religionId}']`);
if ($el) $el.classList.remove("active");
relig
.select("#religion" + religionId)
.transition()
.attr("stroke-width", null)
.attr("stroke", null);
debug
.select("#religionsCenter" + religionId)
.transition()
.attr("r", 4)
.attr("stroke-width", 1.2)
.attr("stroke", null);
}
function religionChangeColor() {
const $el = this;
const currentFill = $el.getAttribute("fill");
const religionId = +$el.parentNode.dataset.id;
const callback = newFill => {
$el.fill = newFill;
pack.religions[religionId].color = newFill;
relig.select("#religion" + religionId).attr("fill", newFill);
debug.select("#religionsCenter" + religionId).attr("fill", newFill);
};
openPicker(currentFill, callback);
}
function religionChangeName() {
const religionId = +this.parentNode.dataset.id;
this.parentNode.dataset.name = this.value;
pack.religions[religionId].name = this.value;
pack.religions[religionId].code = abbreviate(
this.value,
pack.religions.map(c => c.code)
);
}
function religionChangeType() {
const religionId = +this.parentNode.dataset.id;
this.parentNode.dataset.type = this.value;
pack.religions[religionId].type = this.value;
}
function religionChangeForm() {
const religionId = +this.parentNode.dataset.id;
this.parentNode.dataset.form = this.value;
pack.religions[religionId].form = this.value;
}
function religionChangeDeity() {
const religionId = +this.parentNode.dataset.id;
this.parentNode.dataset.deity = this.value;
pack.religions[religionId].deity = this.value;
}
function regenerateDeity() {
const religionId = +this.parentNode.dataset.id;
const cultureId = pack.religions[religionId].culture;
const deity = Religions.getDeityName(cultureId);
this.parentNode.dataset.deity = deity;
pack.religions[religionId].deity = deity;
this.nextElementSibling.value = deity;
}
function changePopulation() {
const religionId = +this.parentNode.dataset.id;
const religion = pack.religions[religionId];
if (!religion.cells) return tip("Religion does not have any cells, cannot change population", false, "error");
const rural = rn(religion.rural * populationRate);
const urban = rn(religion.urban * populationRate * urbanization);
const total = rural + urban;
const format = n => Number(n).toLocaleString();
const burgs = pack.burgs.filter(b => !b.removed && pack.cells.religion[b.cell] === religionId);
alertMessage.innerHTML = /* html */ `<div>
<i>All population of religion territory is considered believers of this religion. It means believers number change will directly affect population</i>
<div style="margin: 0.5em 0">
Rural: <input type="number" min="0" step="1" id="ruralPop" value=${rural} style="width:6em" />
Urban: <input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em"
${burgs.length ? "" : "disabled"} />
</div>
<div>Total population: ${format(total)} <span id="totalPop">${format(total)}</span>
(<span id="totalPopPerc">100</span>%)
</div>
</div>`;
const update = function () {
const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber;
if (isNaN(totalNew)) return;
totalPop.innerHTML = format(totalNew);
totalPopPerc.innerHTML = rn((totalNew / total) * 100);
};
ruralPop.oninput = () => update();
urbanPop.oninput = () => update();
$("#alert").dialog({
resizable: false,
title: "Change believers number",
width: "24em",
buttons: {
Apply: function () {
applyPopulationChange();
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
function applyPopulationChange() {
const ruralChange = ruralPop.value / rural;
if (isFinite(ruralChange) && ruralChange !== 1) {
const cells = pack.cells.i.filter(i => pack.cells.religion[i] === religionId);
cells.forEach(i => (pack.cells.pop[i] *= ruralChange));
}
if (!isFinite(ruralChange) && +ruralPop.value > 0) {
const points = ruralPop.value / populationRate;
const cells = pack.cells.i.filter(i => pack.cells.religion[i] === religionId);
const pop = rn(points / cells.length);
cells.forEach(i => (pack.cells.pop[i] = pop));
}
const urbanChange = urbanPop.value / urban;
if (isFinite(urbanChange) && urbanChange !== 1) {
burgs.forEach(b => (b.population = rn(b.population * urbanChange, 4)));
}
if (!isFinite(urbanChange) && +urbanPop.value > 0) {
const points = urbanPop.value / populationRate / urbanization;
const population = rn(points / burgs.length, 4);
burgs.forEach(b => (b.population = population));
}
refreshReligionsEditor();
}
}
function religionRemovePrompt() {
if (customization) return;
const religionId = +this.parentNode.dataset.id;
confirmationDialog({
title: "Remove religion",
message: "Are you sure you want to remove the religion? <br>This action cannot be reverted",
confirm: "Remove",
onConfirm: () => removeReligion(religionId)
});
}
function removeReligion(religionId) {
relig.select("#religion" + religionId).remove();
relig.select("#religion-gap" + religionId).remove();
debug.select("#religionsCenter" + religionId).remove();
pack.cells.religion.forEach((r, i) => {
if (r === religionId) pack.cells.religion[i] = 0;
});
pack.religions[religionId].removed = true;
pack.religions
.filter(r => r.i && !r.removed)
.forEach(r => {
r.origins = r.origins.filter(origin => origin !== religionId);
if (!r.origins.length) r.origins = [0];
});
refreshReligionsEditor();
}
function drawReligionCenters() {
debug.select("#religionCenters").remove();
const religionCenters = debug
.append("g")
.attr("id", "religionCenters")
.attr("stroke-width", 1.2)
.attr("stroke", "#444444")
.style("cursor", "move");
const data = pack.religions.filter(r => r.i && r.center && r.cells && !r.removed);
religionCenters
.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("id", d => "religionsCenter" + d.i)
.attr("data-id", d => d.i)
.attr("r", 4)
.attr("fill", d => d.color)
.attr("cx", d => pack.cells.p[d.center][0])
.attr("cy", d => pack.cells.p[d.center][1])
.on("mouseenter", d => {
tip(d.name + ". Drag to move the religion center", true);
religionHighlightOn(event);
})
.on("mouseleave", d => {
tip("", true);
religionHighlightOff(event);
})
.call(d3.drag().on("start", religionCenterDrag));
}
function religionCenterDrag() {
const $el = d3.select(this);
const religionId = +this.dataset.id;
d3.event.on("drag", () => {
const {x, y} = d3.event;
$el.attr("cx", x).attr("cy", y);
const cell = findCell(x, y);
if (pack.cells.h[cell] < 20) return; // ignore dragging on water
pack.religions[religionId].center = cell;
});
}
function toggleLegend() {
if (legend.selectAll("*").size()) return clearLegend(); // hide legend
const data = pack.religions
.filter(r => r.i && !r.removed && r.area)
.sort((a, b) => b.area - a.area)
.map(r => [r.i, r.color, r.name]);
drawLegend("Religions", data);
}
function togglePercentageMode() {
if ($body.dataset.type === "absolute") {
$body.dataset.type = "percentage";
const totalArea = +byId("religionsFooterArea").dataset.area;
const totalPopulation = +byId("religionsFooterPopulation").dataset.population;
$body.querySelectorAll(":scope > div").forEach($el => {
const {area, population} = $el.dataset;
$el.querySelector(".religionArea").innerText = rn((+area / totalArea) * 100) + "%";
$el.querySelector(".religionPopulation").innerText = rn((+population / totalPopulation) * 100) + "%";
});
} else {
$body.dataset.type = "absolute";
religionsEditorAddLines();
}
}
async function showHierarchy() {
if (customization) return;
const HeirarchyTree = await import("../hierarchy-tree.js");
const getDescription = religion => {
const {name, type, form, rural, urban} = religion;
const getTypeText = () => {
if (name.includes(type)) return "";
if (form.includes(type)) return "";
if (type === "Folk" || type === "Organized") return `. ${type} religion`;
return `. ${type}`;
};
const formText = form === type ? "" : ". " + form;
const population = rural * populationRate + urban * populationRate * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name}${getTypeText()}${formText}. ${populationText}`;
};
const getShape = ({type}) => {
if (type === "Folk") return "circle";
if (type === "Organized") return "square";
if (type === "Cult") return "hexagon";
if (type === "Heresy") return "diamond";
};
HeirarchyTree.open({
type: "religions",
data: pack.religions,
onNodeEnter: religionHighlightOn,
onNodeLeave: religionHighlightOff,
getDescription,
getShape
});
}
function toggleExtinct() {
$body.dataset.extinct = $body.dataset.extinct !== "show" ? "show" : "hide";
religionsEditorAddLines();
}
function enterReligionsManualAssignent() {
if (!layerIsOn("toggleReligions")) toggleReligions();
customization = 7;
relig.append("g").attr("id", "temp");
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "none"));
byId("religionsManuallyButtons").style.display = "inline-block";
debug.select("#religionCenters").style("display", "none");
religionsEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
religionsFooter.style.display = "none";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
$("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
tip("Click on religion to select, drag the circle to change religion", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectReligionOnMapClick)
.call(d3.drag().on("start", dragReligionBrush))
.on("touchmove mousemove", moveReligionBrush);
$body.querySelector("div").classList.add("selected");
}
function selectReligionOnLineClick(i) {
if (customization !== 7) return;
$body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectReligionOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = relig.select("#temp").select("polygon[data-cell='" + i + "']");
const religion = assigned.size() ? +assigned.attr("data-religion") : pack.cells.religion[i];
$body.querySelector("div.selected").classList.remove("selected");
$body.querySelector("div[data-id='" + religion + "']").classList.add("selected");
}
function dragReligionBrush() {
const radius = +byId("religionsManuallyBrushNumber").value;
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const [x, y] = d3.mouse(this);
moveCircle(x, y, radius);
const found = radius > 5 ? findAll(x, y, radius) : [findCell(x, y, radius)];
const selection = found.filter(isLand);
if (selection) changeReligionForSelection(selection);
});
}
// change religion within selection
function changeReligionForSelection(selection) {
const temp = relig.select("#temp");
const selected = $body.querySelector("div.selected");
const religionNew = +selected.dataset.id;
const color = pack.religions[religionNew].color || "#ffffff";
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const religionOld = exists.size() ? +exists.attr("data-religion") : pack.cells.religion[i];
if (religionNew === religionOld) return;
// change of append new element
if (exists.size()) exists.attr("data-religion", religionNew).attr("fill", color);
else
temp
.append("polygon")
.attr("data-cell", i)
.attr("data-religion", religionNew)
.attr("points", getPackPolygon(i))
.attr("fill", color);
});
}
function moveReligionBrush() {
showMainTip();
const [x, y] = d3.mouse(this);
const radius = +byId("religionsManuallyBrushNumber").value;
moveCircle(x, y, radius);
}
function applyReligionsManualAssignent() {
const changed = relig.select("#temp").selectAll("polygon");
changed.each(function () {
const i = +this.dataset.cell;
const r = +this.dataset.religion;
pack.cells.religion[i] = r;
});
if (changed.size()) {
drawReligions();
refreshReligionsEditor();
drawReligionCenters();
}
exitReligionsManualAssignment();
}
function exitReligionsManualAssignment(close) {
customization = 0;
relig.select("#temp").remove();
removeCircle();
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "inline-block"));
byId("religionsManuallyButtons").style.display = "none";
byId("religionsEditor")
.querySelectorAll(".hide")
.forEach(el => el.classList.remove("hidden"));
byId("religionsFooter").style.display = "block";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close) $("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
debug.select("#religionCenters").style("display", null);
restoreDefaultEvents();
clearMainTip();
const $selected = $body.querySelector("div.selected");
if ($selected) $selected.classList.remove("selected");
}
function enterAddReligionMode() {
if (this.classList.contains("pressed")) return exitAddReligionMode();
customization = 8;
this.classList.add("pressed");
tip("Click on the map to add a new religion", true);
viewbox.style("cursor", "crosshair").on("click", addReligion);
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
}
function exitAddReligionMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (religionsAdd.classList.contains("pressed")) religionsAdd.classList.remove("pressed");
}
function addReligion() {
const [x, y] = d3.mouse(this);
const center = findCell(x, y);
if (pack.cells.h[center] < 20)
return tip("You cannot place religion center into the water. Please click on a land cell", false, "error");
const occupied = pack.religions.some(r => !r.removed && r.center === center);
if (occupied) return tip("This cell is already a religion center. Please select a different cell", false, "error");
if (d3.event.shiftKey === false) exitAddReligionMode();
Religions.add(center);
drawReligions();
refreshReligionsEditor();
drawReligionCenters();
}
function downloadReligionsCsv() {
const unit = getAreaUnit("2");
const headers = `Id,Name,Color,Type,Form,Supreme Deity,Area ${unit},Believers,Origins`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, color, type, form, deity, area, population} = $line.dataset;
const deityText = '"' + deity + '"';
const {origins} = pack.religions[+id];
const originList = (origins || []).filter(origin => origin).map(origin => pack.religions[origin].name);
const originText = '"' + originList.join(", ") + '"';
return [id, name, color, type, form, deityText, area, population, originText].join(",");
});
const csvData = [headers].concat(data).join("\n");
const name = getFileName("Religions") + ".csv";
downloadFile(csvData, name);
}
function closeReligionsEditor() {
debug.select("#religionCenters").remove();
exitReligionsManualAssignment("close");
exitAddReligionMode();
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import {tip} from "scripts/tooltips";
import {closeDialogs} from "dialogs/utils";
export function exportToJson(type) {
if (customization)

View file

@ -1,351 +0,0 @@
import * as d3 from "d3";
import {heightmapTemplates} from "config/heightmap-templates";
import {precreatedHeightmaps} from "config/precreated-heightmaps";
import {shouldRegenerateGrid, generateGrid} from "utils/graphUtils";
import {byId} from "utils/shorthands";
import {generateSeed} from "utils/probabilityUtils";
import {getColorScheme} from "utils/colorUtils";
import {aleaPRNG} from "scripts/aleaPRNG";
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();
applyDropdownOption($templateInput, id, getName(id));
lock("template");
$(this).dialog("close");
},
"New Map": function () {
const id = getSelected();
applyDropdownOption($templateInput, id, getName(id));
lock("template");
const seed = getSeed();
regeneratePrompt({seed, graph});
$(this).dialog("close");
}
}
});
}
function appendStyleSheet() {
const style = document.createElement("style");
style.textContent = /* css */ `
div.dialog > div.heightmap-selection {
width: 70vw;
height: 70vh;
}
.heightmap-selection_container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-gap: 6px;
}
@media (max-width: 600px) {
.heightmap-selection_container {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
grid-gap: 4px;
}
}
@media (min-width: 2000px) {
.heightmap-selection_container {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 8px;
}
}
.heightmap-selection_options {
display: grid;
grid-template-columns: 2fr 1fr;
}
.heightmap-selection_options > div:first-child {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
justify-self: start;
justify-items: start;
}
@media (max-width: 600px) {
.heightmap-selection_options {
grid-template-columns: 3fr 1fr;
}
.heightmap-selection_options > div:first-child {
display: block;
}
}
.heightmap-selection_options > div:last-child {
justify-self: end;
}
.heightmap-selection article {
padding: 4px;
border-radius: 8px;
transition: all 0.1s ease-in-out;
filter: drop-shadow(1px 1px 4px #999);
}
.heightmap-selection article:hover {
background-color: #ddd;
filter: drop-shadow(1px 1px 8px #999);
cursor: pointer;
}
.heightmap-selection article.selected {
background-color: #ccc;
outline: 1px solid var(--dark-solid);
filter: drop-shadow(1px 1px 8px #999);
}
.heightmap-selection article > div {
display: flex;
justify-content: space-between;
padding: 2px 1px;
}
.heightmap-selection article > img {
width: 100%;
aspect-ratio: ${graphWidth}/${graphHeight};
border-radius: 8px;
object-fit: fill;
}
.heightmap-selection article .regeneratePreview {
outline: 1px solid #bbb;
padding: 1px 3px;
border-radius: 4px;
transition: all 0.1s ease-in-out;
}
.heightmap-selection article .regeneratePreview:hover {
outline: 1px solid #666;
}
.heightmap-selection article .regeneratePreview:active {
outline: 1px solid #333;
color: #000;
transform: rotate(45deg);
}
`;
document.head.appendChild(style);
}
function insertHtml() {
const heightmapSelectionHtml = /* html */ `<div id="heightmapSelection" class="dialog stable">
<div class="heightmap-selection">
<section data-tip="Select heightmap template template provides unique, but similar-looking maps on generation">
<header><h1>Heightmap templates</h1></header>
<div class="heightmap-selection_container"></div>
</section>
<section data-tip="Select precreated heightmap it will be the same for each map">
<header><h1>Precreated heightmaps</h1></header>
<div class="heightmap-selection_container"></div>
</section>
<section>
<header><h1>Options</h1></header>
<div class="heightmap-selection_options">
<div>
<label data-tip="Rerender all preview images" class="checkbox-label" id="heightmapSelectionRedrawPreview">
<i class="icon-cw"></i>
Redraw preview
</label>
<div>
<input id="heightmapSelectionRenderOcean" class="checkbox" type="checkbox" />
<label data-tip="Draw heights of water cells" for="heightmapSelectionRenderOcean" class="checkbox-label">Render ocean heights</label>
</div>
<div data-tip="Color scheme used for heightmap preview">
Color scheme
<select id="heightmapSelectionColorScheme">
<option value="bright" selected>Bright</option>
<option value="light">Light</option>
<option value="green">Green</option>
<option value="monochrome">Monochrome</option>
</select>
</div>
</div>
<div>
<button data-tip="Open Template Editor" data-tool="templateEditor" id="heightmapSelectionEditTemplates">Edit Templates</button>
<button data-tip="Open Image Converter" data-tool="imageConverter" id="heightmapSelectionImportHeightmap">Import Heightmap</button>
</div>
</div>
</section>
</div>
</div>`;
byId("dialogs").insertAdjacentHTML("beforeend", heightmapSelectionHtml);
const sections = document.getElementsByClassName("heightmap-selection_container");
sections[0].innerHTML = Object.keys(heightmapTemplates)
.map(key => {
const name = heightmapTemplates[key].name;
Math.random = aleaPRNG(initialSeed);
const heights = HeightmapGenerator.fromTemplate(graph, key);
const dataUrl = drawHeights(heights);
return /* html */ `<article data-id="${key}" data-seed="${initialSeed}">
<img src="${dataUrl}" alt="${name}" />
<div>
${name}
<span data-tip="Regenerate preview" class="icon-cw regeneratePreview"></span>
</div>
</article>`;
})
.join("");
sections[1].innerHTML = Object.keys(precreatedHeightmaps)
.map(key => {
const name = precreatedHeightmaps[key].name;
drawPrecreatedHeightmap(key);
return /* html */ `<article data-id="${key}" data-seed="${initialSeed}">
<img alt="${name}" />
<div>${name}</div>
</article>`;
})
.join("");
}
function addListeners() {
byId("heightmapSelection").on("click", event => {
const article = event.target.closest("#heightmapSelection article");
if (!article) return;
const id = article.dataset.id;
if (event.target.matches("span.icon-cw")) regeneratePreview(article, id);
setSelected(id);
});
byId("heightmapSelectionRenderOcean").on("change", redrawAll);
byId("heightmapSelectionColorScheme").on("change", redrawAll);
byId("heightmapSelectionRedrawPreview").on("click", redrawAll);
byId("heightmapSelectionEditTemplates").on("click", confirmHeightmapEdit);
byId("heightmapSelectionImportHeightmap").on("click", confirmHeightmapEdit);
}
function getSelected() {
return byId("heightmapSelection").querySelector(".selected")?.dataset?.id;
}
function setSelected(id) {
const $heightmapSelection = byId("heightmapSelection");
$heightmapSelection.querySelector(".selected")?.classList?.remove("selected");
$heightmapSelection.querySelector(`[data-id="${id}"]`)?.classList?.add("selected");
}
function getSeed() {
return byId("heightmapSelection").querySelector(".selected")?.dataset?.seed;
}
function getName(id) {
const isTemplate = id in heightmapTemplates;
return isTemplate ? heightmapTemplates[id].name : precreatedHeightmaps[id].name;
}
function getGraph(currentGraph) {
const newGraph = shouldRegenerateGrid(currentGraph) ? generateGrid() : structuredClone(currentGraph);
delete newGraph.cells.h;
return newGraph;
}
function drawHeights(heights) {
const canvas = document.createElement("canvas");
canvas.width = graph.cellsX;
canvas.height = graph.cellsY;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(graph.cellsX, graph.cellsY);
const schemeId = byId("heightmapSelectionColorScheme").value;
const scheme = getColorScheme(schemeId);
const renderOcean = byId("heightmapSelectionRenderOcean").checked;
const getHeight = height => (height < 20 ? (renderOcean ? height : 0) : height);
for (let i = 0; i < heights.length; i++) {
const color = scheme(1 - getHeight(heights[i]) / 100);
const {r, g, b} = d3.color(color);
const n = i * 4;
imageData.data[n] = r;
imageData.data[n + 1] = g;
imageData.data[n + 2] = b;
imageData.data[n + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
function drawTemplatePreview(id) {
const heights = HeightmapGenerator.fromTemplate(graph, id);
const dataUrl = drawHeights(heights);
const article = byId("heightmapSelection").querySelector(`[data-id="${id}"]`);
article.querySelector("img").src = dataUrl;
}
async function drawPrecreatedHeightmap(id) {
const heights = await HeightmapGenerator.fromPrecreated(graph, id);
const dataUrl = drawHeights(heights);
const article = byId("heightmapSelection").querySelector(`[data-id="${id}"]`);
article.querySelector("img").src = dataUrl;
}
function regeneratePreview(article, id) {
graph = getGraph(graph);
const seed = generateSeed();
article.dataset.seed = seed;
Math.random = aleaPRNG(seed);
drawTemplatePreview(id);
}
function redrawAll() {
graph = getGraph(graph);
const articles = byId("heightmapSelection").querySelectorAll(`article`);
for (const article of articles) {
const {id, seed} = article.dataset;
Math.random = aleaPRNG(seed);
const isTemplate = id in heightmapTemplates;
if (isTemplate) drawTemplatePreview(id);
else drawPrecreatedHeightmap(id);
}
}
function confirmHeightmapEdit() {
const tool = this.dataset.tool;
confirmationDialog({
title: this.dataset.tip,
message: "Opening the tool will erase the current map. Are you sure you want to proceed?",
confirm: "Continue",
onConfirm: () => editHeightmap({mode: "erase", tool})
});
}

View file

@ -1,514 +0,0 @@
import * as d3 from "d3";
import {byId} from "utils/shorthands";
import {tip} from "scripts/tooltips";
import {capitalize} from "utils/stringUtils";
appendStyleSheet();
insertHtml();
const MARGINS = {top: 10, right: 10, bottom: -5, left: 10};
const handleZoom = () => viewbox.attr("transform", d3.event.transform);
const zoom = d3.zoom().scaleExtent([0.2, 1.5]).on("zoom", handleZoom);
// store old root for transitions
let oldRoot;
// define svg elements
const svg = d3.select("#hierarchyTree > svg").call(zoom);
const viewbox = svg.select("g#hierarchyTree_viewbox");
const primaryLinks = viewbox.select("g#hierarchyTree_linksPrimary");
const secondaryLinks = viewbox.select("g#hierarchyTree_linksSecondary");
const nodes = viewbox.select("g#hierarchyTree_nodes");
const dragLine = viewbox.select("path#hierarchyTree_dragLine");
// properties
let dataElements; // {i, name, type, origins}[], e.g. path.religions
let validElements; // not-removed dataElements
let onNodeEnter; // d3Data => void
let onNodeLeave; // d3Data => void
let getDescription; // dataElement => string
let getShape; // dataElement => string;
export function open(props) {
closeDialogs("#hierarchyTree, .stable");
dataElements = props.data;
dataElements[0].origins = [null];
validElements = dataElements.filter(r => !r.removed);
if (validElements.length < 3) return tip(`Not enough ${props.type} to show hierarchy`, false, "error");
onNodeEnter = props.onNodeEnter;
onNodeLeave = props.onNodeLeave;
getDescription = props.getDescription;
getShape = props.getShape;
const root = getRoot();
const treeWidth = root.leaves().length * 50;
const treeHeight = root.height * 50;
const w = treeWidth - MARGINS.left - MARGINS.right;
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
const treeLayout = d3.tree().size([w, h]);
const width = minmax(treeWidth, 300, innerWidth * 0.75);
const height = minmax(treeHeight, 200, innerHeight * 0.75);
zoom.extent([Array(2).fill(0), [width, height]]);
svg.attr("viewBox", `0, 0, ${width}, ${height}`);
$("#hierarchyTree").dialog({
title: `${capitalize(props.type)} tree`,
position: {my: "left center", at: "left+10 center", of: "svg"},
width
});
renderTree(root, treeLayout);
}
function appendStyleSheet() {
const style = document.createElement("style");
style.textContent = /* css */ `
#hierarchyTree_selectedOrigins > button {
margin: 0 2px;
}
#hierarchyTree {
display: flex;
flex-direction: column;
justify-content: space-between;
}
#hierarchyTree > svg {
height: 100%;
}
.hierarchyTree_selectedOrigins {
margin-right: 15px;
}
.hierarchyTree_selectedOrigin {
border: 1px solid #aaa;
background: none;
padding: 1px 4px;
}
.hierarchyTree_selectedOrigin:hover {
border: 1px solid #333;
}
.hierarchyTree_selectedOrigin::after {
content: "✕";
margin-left: 8px;
color: #999;
}
.hierarchyTree_selectedOrigin:hover:after {
color: #333;
}
#hierarchyTree_originSelector {
display: none;
}
#hierarchyTree_originSelector > form > div {
padding: 0.3em;
margin: 1px 0;
border-radius: 1em;
}
#hierarchyTree_originSelector > form > div:hover {
background-color: #ddd;
}
#hierarchyTree_originSelector > form > div[checked] {
background-color: #c6d6d6;
}
#hierarchyTree_nodes > g > text {
pointer-events: none;
stroke: none;
font-size: 11px;
}
#hierarchyTree_nodes > g.selected {
stroke: #c13119;
stroke-width: 1;
cursor: move;
}
#hierarchyTree_dragLine {
marker-end: url(#end-arrow);
stroke: #333333;
stroke-dasharray: 5;
stroke-dashoffset: 1000;
animation: dash 80s linear backwards;
}
`;
document.head.appendChild(style);
}
function insertHtml() {
const html = /* html */ `<div id="hierarchyTree" class="dialog" style="overflow: hidden;">
<svg>
<g id="hierarchyTree_viewbox" style="text-anchor: middle; dominant-baseline: central">
<g transform="translate(10, -45)">
<g id="hierarchyTree_links" fill="none" stroke="#aaa">
<g id="hierarchyTree_linksPrimary"></g>
<g id="hierarchyTree_linksSecondary" stroke-dasharray="1"></g>
</g>
<g id="hierarchyTree_nodes"></g>
<path id="hierarchyTree_dragLine" path='' />
</g>
</g>
</svg>
<div id="hierarchyTree_details" class='chartInfo'>
<div id='hierarchyTree_infoLine' style="display: block">&#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 getRoot() {
const root = d3
.stratify()
.id(d => d.i)
.parentId(d => d.origins[0])(validElements);
oldRoot = root;
return root;
}
function getLinkKey(d) {
return `${d.source.id}-${d.target.id}`;
}
function getNodeKey(d) {
return d.id;
}
function getLinkPath(d) {
const {
source: {x: sx, y: sy},
target: {x: tx, y: ty}
} = d;
return `M${sx},${sy} C${sx},${(sy * 3 + ty) / 4} ${tx},${(sy * 2 + ty) / 3} ${tx},${ty}`;
}
function getSecondaryLinks(root) {
const nodes = root.descendants();
const links = [];
for (const node of nodes) {
const origins = node.data.origins;
for (let i = 1; i < origins.length; i++) {
const source = nodes.find(n => n.data.i === origins[i]);
if (source) links.push({source, target: node});
}
}
return links;
}
const shapesMap = {
undefined: "M5,0A5,5,0,1,1,-5,0A5,5,0,1,1,5,0", // small circle
circle: "M11.3,0A11.3,11.3,0,1,1,-11.3,0A11.3,11.3,0,1,1,11.3,0",
square: "M-11,-11h22v22h-22Z",
hexagon: "M-6.5,-11.26l13,0l6.5,11.26l-6.5,11.26l-13,0l-6.5,-11.26Z",
diamond: "M0,-14L14,0L0,14L-14,0Z",
concave: "M-11,-11l11,2l11,-2l-2,11l2,11l-11,-2l-11,2l2,-11Z",
octagon: "M-4.97,-12.01 l9.95,0 l7.04,7.04 l0,9.95 l-7.04,7.04 l-9.95,0 l-7.04,-7.04 l0,-9.95Z",
pentagon: "M0,-14l14,11l-6,14h-16l-6,-14Z"
};
const getSortIndex = node => {
const descendants = node.descendants();
const secondaryOrigins = descendants.map(({data}) => data.origins.slice(1)).flat();
if (secondaryOrigins.length === 0) return node.data.i;
return d3.mean(secondaryOrigins);
};
function renderTree(root, treeLayout) {
treeLayout(root.sort((a, b) => getSortIndex(a) - getSortIndex(b)));
primaryLinks.selectAll("path").data(root.links(), getLinkKey).join("path").attr("d", getLinkPath);
secondaryLinks.selectAll("path").data(getSecondaryLinks(root), getLinkKey).join("path").attr("d", getLinkPath);
const node = nodes
.selectAll("g")
.data(root.descendants(), getNodeKey)
.join("g")
.attr("data-id", d => d.data.i)
.attr("stroke", "#333")
.attr("transform", d => `translate(${d.x}, ${d.y})`)
.on("mouseenter", handleNoteEnter)
.on("mouseleave", handleNodeExit)
.on("click", selectElement)
.call(d3.drag().on("start", dragToReorigin));
node
.selectAll("path")
.data(d => [d])
.join("path")
.attr("d", d => shapesMap[getShape(d.data)])
.attr("fill", d => d.data.color || "#ffffff")
.attr("stroke-dasharray", d => (d.data.cells ? "none" : "1"));
node
.selectAll("text")
.data(d => [d])
.join("text")
.text(d => d.data.code || "");
}
function mapCoords(newRoot, prevRoot) {
newRoot.x = prevRoot.x;
newRoot.y = prevRoot.y;
for (const node of newRoot.descendants()) {
const prevNode = prevRoot.descendants().find(n => n.data.i === node.data.i);
if (prevNode) {
node.x = prevNode.x;
node.y = prevNode.y;
}
}
}
function updateTree() {
const prevRoot = oldRoot;
const root = getRoot();
mapCoords(root, prevRoot);
const linksUpdateDuration = 50;
const moveDuration = 1000;
// old layout: update links at old nodes positions
const linkEnter = enter =>
enter
.append("path")
.attr("d", getLinkPath)
.attr("opacity", 0)
.call(enter => enter.transition().duration(linksUpdateDuration).attr("opacity", 1));
const linkUpdate = update =>
update.call(update => update.transition().duration(linksUpdateDuration).attr("d", getLinkPath));
const linkExit = exit =>
exit.call(exit => exit.transition().duration(linksUpdateDuration).attr("opacity", 0).remove());
primaryLinks.selectAll("path").data(root.links(), getLinkKey).join(linkEnter, linkUpdate, linkExit);
secondaryLinks.selectAll("path").data(getSecondaryLinks(root), getLinkKey).join(linkEnter, linkUpdate, linkExit);
// new layout: move nodes with links to new positions
const treeWidth = root.leaves().length * 50;
const treeHeight = root.height * 50;
const w = treeWidth - MARGINS.left - MARGINS.right;
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
const treeLayout = d3.tree().size([w, h]);
treeLayout(root.sort((a, b) => getSortIndex(a) - getSortIndex(b)));
primaryLinks
.selectAll("path")
.data(root.links(), getLinkKey)
.transition()
.duration(moveDuration)
.delay(linksUpdateDuration)
.attr("d", getLinkPath);
secondaryLinks
.selectAll("path")
.data(getSecondaryLinks(root), getLinkKey)
.transition()
.duration(moveDuration)
.delay(linksUpdateDuration)
.attr("d", getLinkPath);
nodes
.selectAll("g")
.data(root.descendants(), getNodeKey)
.transition()
.delay(linksUpdateDuration)
.duration(moveDuration)
.attr("transform", d => `translate(${d.x},${d.y})`);
}
function selectElement(d) {
const dataElement = d.data;
if (d.id == 0) return;
const node = nodes.select(`g[data-id="${d.id}"]`);
nodes.selectAll("g").style("outline", "none");
node.style("outline", "1px solid #c13119");
byId("hierarchyTree_selected").style.display = "block";
byId("hierarchyTree_infoLine").style.display = "none";
byId("hierarchyTree_selectedName").innerText = dataElement.name;
byId("hierarchyTree_selectedCode").value = dataElement.code;
byId("hierarchyTree_selectedCode").onchange = function () {
if (this.value.length > 3) return tip("Abbreviation must be 3 characters or less", false, "error", 3000);
if (!this.value.length) return tip("Abbreviation cannot be empty", false, "error", 3000);
node.select("text").text(this.value);
dataElement.code = this.value;
};
const createOriginButtons = () => {
byId("hierarchyTree_selectedOrigins").innerHTML = dataElement.origins
.filter(origin => origin)
.map((origin, index) => {
const {name, code} = validElements.find(r => r.i === origin) || {};
const type = index ? "Secondary" : "Primary";
const tip = `${type} origin: ${name}. Click to remove link to that origin`;
return `<button data-id="${origin}" class="hierarchyTree_selectedButton hierarchyTree_selectedOrigin" data-tip="${tip}">${code}</button>`;
})
.join("");
byId("hierarchyTree_selectedOrigins").onclick = event => {
const target = event.target;
if (target.tagName !== "BUTTON") return;
const origin = Number(target.dataset.id);
const filtered = dataElement.origins.filter(elementOrigin => elementOrigin !== origin);
dataElement.origins = filtered.length ? filtered : [0];
target.remove();
updateTree();
};
};
createOriginButtons();
byId("hierarchyTree_selectedSelectButton").onclick = () => {
const origins = dataElement.origins;
const descendants = d.descendants().map(d => d.data.i);
const selectableElements = validElements.filter(({i}) => !descendants.includes(i));
const selectableElementsHtml = selectableElements.map(({i, name, code, color}) => {
const isPrimary = origins[0] === i ? "checked" : "";
const isChecked = origins.includes(i) ? "checked" : "";
if (i === 0) {
return /*html*/ `
<div ${isChecked}>
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
Top level
</div>
`;
}
return /*html*/ `
<div ${isChecked}>
<input data-tip="Set as primary origin" type="radio" name="primary" value="${i}" ${isPrimary} />
<input data-id="${i}" id="selectElementOrigin${i}" class="checkbox" type="checkbox" ${isChecked} />
<label data-tip="Check to set as a secondary origin" for="selectElementOrigin${i}" class="checkbox-label">
<fill-box fill="${color}" size=".8em" disabled></fill-box>
${code}: ${name}</label>
</div>
`;
});
byId("hierarchyTree_originSelector").innerHTML = /*html*/ `
<form style="max-height: 35vh">
${selectableElementsHtml.join("")}
</form>
`;
$("#hierarchyTree_originSelector").dialog({
title: "Select origins",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Select: () => {
$("#hierarchyTree_originSelector").dialog("close");
const $selector = byId("hierarchyTree_originSelector");
const selectedRadio = $selector.querySelector("input[type='radio']:checked");
const selectedCheckboxes = $selector.querySelectorAll("input[type='checkbox']:checked");
const primary = selectedRadio ? Number(selectedRadio.value) : 0;
const secondary = Array.from(selectedCheckboxes)
.map(input => Number(input.dataset.id))
.filter(origin => origin !== primary);
dataElement.origins = [primary, ...secondary];
updateTree();
createOriginButtons();
},
Cancel: () => {
$("#hierarchyTree_originSelector").dialog("close");
}
}
});
};
byId("hierarchyTree_selectedCloseButton").onclick = () => {
node.style("outline", "none");
byId("hierarchyTree_selected").style.display = "none";
byId("hierarchyTree_infoLine").style.display = "block";
};
}
function handleNoteEnter(d) {
if (d.depth === 0) return;
this.classList.add("selected");
onNodeEnter(d);
byId("hierarchyTree_infoLine").innerText = getDescription(d.data);
tip("Drag to other node to add parent, click to edit");
}
function handleNodeExit(d) {
this.classList.remove("selected");
onNodeLeave(d);
byId("hierarchyTree_infoLine").innerHTML = "&#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

@ -1,705 +0,0 @@
import * as d3 from "d3";
import {isWater} from "utils/graphUtils";
import {tip} from "scripts/tooltips";
import {byId} from "utils/shorthands";
import {rn} from "utils/numberUtils";
import {capitalize} from "utils/stringUtils";
import {si, convertTemperature, getFriendlyPrecipitation} from "utils/unitUtils";
import {rollups} from "utils/functionUtils";
const entitiesMap = {
states: {
label: "State",
getCellsData: () => pack.cells.state,
getName: nameGetter("states"),
getColors: colorsGetter("states"),
landOnly: true
},
cultures: {
label: "Culture",
getCellsData: () => pack.cells.culture,
getName: nameGetter("cultures"),
getColors: colorsGetter("cultures"),
landOnly: true
},
religions: {
label: "Religion",
getCellsData: () => pack.cells.religion,
getName: nameGetter("religions"),
getColors: colorsGetter("religions"),
landOnly: true
},
provinces: {
label: "Province",
getCellsData: () => pack.cells.province,
getName: nameGetter("provinces"),
getColors: colorsGetter("provinces"),
landOnly: true
},
biomes: {
label: "Biome",
getCellsData: () => pack.cells.biome,
getName: biomeNameGetter,
getColors: biomeColorsGetter,
landOnly: false
}
};
const quantizationMap = {
total_population: {
label: "Total population",
quantize: cellId => getUrbanPopulation(cellId) + getRuralPopulation(cellId),
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
urban_population: {
label: "Urban population",
quantize: getUrbanPopulation,
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
rural_population: {
label: "Rural population",
quantize: getRuralPopulation,
aggregate: values => rn(d3.sum(values)),
formatTicks: value => si(value),
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
area: {
label: "Land area",
quantize: cellId => getArea(pack.cells.area[cellId]),
aggregate: values => rn(d3.sum(values)),
formatTicks: value => `${si(value)} ${getAreaUnit()}`,
stringify: value => `${value.toLocaleString()} ${getAreaUnit()}`,
stackable: true,
landOnly: true
},
cells: {
label: "Number of cells",
quantize: () => 1,
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
burgs_number: {
label: "Number of burgs",
quantize: cellId => (pack.cells.burg[cellId] ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
average_elevation: {
label: "Average elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.mean(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
max_elevation: {
label: "Maximum mean elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.max(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
min_elevation: {
label: "Minimum mean elevation",
quantize: cellId => pack.cells.h[cellId],
aggregate: values => d3.min(values),
formatTicks: value => getHeight(value),
stringify: value => getHeight(value),
stackable: false,
landOnly: false
},
average_temperature: {
label: "Annual mean temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.mean(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
max_temperature: {
label: "Mean annual maximum temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.max(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
min_temperature: {
label: "Mean annual minimum temperature",
quantize: cellId => grid.cells.temp[pack.cells.g[cellId]],
aggregate: values => d3.min(values),
formatTicks: value => convertTemperature(value),
stringify: value => convertTemperature(value),
stackable: false,
landOnly: false
},
average_precipitation: {
label: "Annual mean precipitation",
quantize: cellId => grid.cells.prec[pack.cells.g[cellId]],
aggregate: values => rn(d3.mean(values)),
formatTicks: value => getFriendlyPrecipitation(rn(value)),
stringify: value => getFriendlyPrecipitation(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 => getFriendlyPrecipitation(rn(value)),
stringify: value => getFriendlyPrecipitation(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 => getFriendlyPrecipitation(rn(value)),
stringify: value => getFriendlyPrecipitation(rn(value)),
stackable: false,
landOnly: true
},
coastal_cells: {
label: "Number of coastal cells",
quantize: cellId => (pack.cells.t[cellId] === 1 ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
},
river_cells: {
label: "Number of river cells",
quantize: cellId => (pack.cells.r[cellId] ? 1 : 0),
aggregate: values => d3.sum(values),
formatTicks: value => value,
stringify: value => value.toLocaleString(),
stackable: true,
landOnly: true
}
};
const plotTypeMap = {
stackedBar: {offset: d3.stackOffsetDiverging},
normalizedStackedBar: {offset: d3.stackOffsetExpand, formatX: value => rn(value * 100) + "%"}
};
let charts = []; // store charts data
let prevMapId = mapId;
appendStyleSheet();
insertHtml();
addListeners();
changeViewColumns();
export function open() {
closeDialogs("#chartsOverview, .stable");
if (prevMapId !== mapId) {
charts = [];
prevMapId = mapId;
}
if (!charts.length) addChart();
else charts.forEach(chart => renderChart(chart));
$("#chartsOverview").dialog({
title: "Data Charts",
position: {my: "center", at: "center", of: "svg"},
close: handleClose
});
}
function appendStyleSheet() {
const style = document.createElement("style");
style.textContent = /* css */ `
#chartsOverview {
max-width: 90vw !important;
max-height: 90vh !important;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
#chartsOverview__form {
font-size: 1.1em;
margin: 0.3em 0;
display: grid;
grid-template-columns: auto auto;
grid-gap: 0.3em;
align-items: start;
justify-items: end;
}
@media (max-width: 600px) {
#chartsOverview__form {
font-size: 1em;
grid-template-columns: 1fr;
justify-items: normal;
}
}
#chartsOverview__charts {
overflow: auto;
scroll-behavior: smooth;
display: grid;
}
#chartsOverview__charts figure {
margin: 0;
}
#chartsOverview__charts figcaption {
font-size: 1.2em;
margin: 0 1% 0 4%;
display: grid;
grid-template-columns: 1fr auto;
}
`;
document.head.appendChild(style);
}
function insertHtml() {
const entities = Object.entries(entitiesMap).map(([entity, {label}]) => [entity, label]);
const plotBy = Object.entries(quantizationMap).map(([plotBy, {label}]) => [plotBy, label]);
const createOption = ([value, label]) => `<option value="${value}">${label}</option>`;
const createOptions = values => values.map(createOption).join("");
const html = /* html */ `<div id="chartsOverview" class="dialog stable">
<form id="chartsOverview__form">
<div>
<button data-tip="Add a chart" type="submit">Plot</button>
<select data-tip="Select entity (y axis)" id="chartsOverview__entitiesSelect">
${createOptions(entities)}
</select>
<label>by
<select data-tip="Select value to plot by (x axis)" id="chartsOverview__plotBySelect">
${createOptions(plotBy)}
</select>
</label>
<label>grouped by
<select data-tip="Select entoty to group by. If you don't need grouping, set it the same as the entity" id="chartsOverview__groupBySelect">
${createOptions(entities)}
</select>
</label>
<label data-tip="Sorting type">sorted
<select id="chartsOverview__sortingSelect">
<option value="value">by value</option>
<option value="name">by name</option>
<option value="natural">naturally</option>
</select>
</label>
</div>
<div>
<span data-tip="Chart type">Type</span>
<select id="chartsOverview__chartType">
<option value="stackedBar" selected>Stacked Bar</option>
<option value="normalizedStackedBar">Normalized Stacked Bar</option>
</select>
<span data-tip="Columns to display">Columns</span>
<select id="chartsOverview__viewColumns">
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
</form>
<section id="chartsOverview__charts"></section>
</div>`;
byId("dialogs").insertAdjacentHTML("beforeend", html);
// set defaults
byId("chartsOverview__entitiesSelect").value = "states";
byId("chartsOverview__plotBySelect").value = "total_population";
byId("chartsOverview__groupBySelect").value = "cultures";
}
function addListeners() {
byId("chartsOverview__form").on("submit", addChart);
byId("chartsOverview__viewColumns").on("change", changeViewColumns);
}
function addChart(event) {
if (event) event.preventDefault();
const entity = byId("chartsOverview__entitiesSelect").value;
const plotBy = byId("chartsOverview__plotBySelect").value;
let groupBy = byId("chartsOverview__groupBySelect").value;
const sorting = byId("chartsOverview__sortingSelect").value;
const type = byId("chartsOverview__chartType").value;
const {stackable} = quantizationMap[plotBy];
if (!stackable && groupBy !== entity) {
tip(`Grouping is not supported for ${plotByLabel}`, false, "warn", 4000);
groupBy = entity;
}
const chartOptions = {id: Date.now(), entity, plotBy, groupBy, sorting, type};
charts.push(chartOptions);
renderChart(chartOptions);
updateDialogPosition();
}
function renderChart({id, entity, plotBy, groupBy, sorting, type}) {
const {
label: plotByLabel,
stringify,
quantize,
aggregate,
formatTicks,
landOnly: plotByLandOnly
} = quantizationMap[plotBy];
const noGrouping = groupBy === entity;
const {
label: entityLabel,
getName: getEntityName,
getCellsData: getEntityCellsData,
landOnly: entityLandOnly
} = entitiesMap[entity];
const {label: groupLabel, getName: getGroupName, getCellsData: getGroupCellsData, getColors} = entitiesMap[groupBy];
const entityCells = getEntityCellsData();
const groupCells = getGroupCellsData();
const title = `${capitalize(entity)} by ${plotByLabel}${noGrouping ? "" : " grouped by " + groupLabel}`;
const tooltip = (entity, group, value, percentage) => {
const entityTip = `${entityLabel}: ${entity}`;
const groupTip = noGrouping ? "" : `${groupLabel}: ${group}`;
let valueTip = `${plotByLabel}: ${stringify(value)}`;
if (!noGrouping) valueTip += ` (${rn(percentage * 100)}%)`;
return [entityTip, groupTip, valueTip].filter(Boolean);
};
const dataCollection = {};
const groups = new Set();
for (const cellId of pack.cells.i) {
if ((entityLandOnly || plotByLandOnly) && isWater(cellId)) continue;
const entityId = entityCells[cellId];
const groupId = groupCells[cellId];
const value = quantize(cellId);
if (!dataCollection[entityId]) dataCollection[entityId] = {[groupId]: [value]};
else if (!dataCollection[entityId][groupId]) dataCollection[entityId][groupId] = [value];
else dataCollection[entityId][groupId].push(value);
groups.add(groupId);
}
const chartData = Object.entries(dataCollection)
.map(([entityId, groupData]) => {
const name = getEntityName(entityId);
return Object.entries(groupData).map(([groupId, values]) => {
const group = getGroupName(groupId);
const value = aggregate(values);
return {name, group, value};
});
})
.flat();
const colors = getColors();
const {offset, formatX = formatTicks} = plotTypeMap[type];
const $chart = createStackedBarChart(chartData, {sorting, colors, tooltip, offset, formatX});
insertChart(id, $chart, title);
byId("chartsOverview__charts").lastChild.scrollIntoView();
}
// based on observablehq.com/@d3/stacked-horizontal-bar-chart
function createStackedBarChart(data, {sorting, colors, tooltip, offset, formatX}) {
const sortedData = sortData(data, sorting);
const X = sortedData.map(d => d.value);
const Y = sortedData.map(d => d.name);
const Z = sortedData.map(d => d.group);
const yDomain = new Set(Y);
const zDomain = new Set(Z);
const I = d3.range(X.length).filter(i => yDomain.has(Y[i]) && zDomain.has(Z[i]));
const entities = Array.from(yDomain);
const groups = Array.from(zDomain);
const yScaleMinWidth = getTextMinWidth(entities);
const legendRows = calculateLegendRows(groups, WIDTH - yScaleMinWidth - 15);
const margin = {top: 30, right: 15, bottom: legendRows * 20 + 10, left: yScaleMinWidth};
const xRange = [margin.left, WIDTH - margin.right];
const height = yDomain.size * 25 + margin.top + margin.bottom;
const yRange = [height - margin.bottom, margin.top];
const rolled = rollups(...[I, ([i]) => i, i => Y[i], i => Z[i]]);
const series = d3
.stack()
.keys(groups)
.value(([, I], z) => X[new Map(I).get(z)])
.order(d3.stackOrderNone)
.offset(offset)(rolled)
.map(s => {
const defined = s.filter(d => !isNaN(d[1]));
const data = defined.map(d => Object.assign(d, {i: new Map(d.data[1]).get(s.key)}));
return {key: s.key, data};
});
const xDomain = d3.extent(series.map(d => d.data).flat(2));
const xScale = d3.scaleLinear(xDomain, xRange);
const yScale = d3.scaleBand(entities, yRange).paddingInner(Y_PADDING);
const xAxis = d3.axisTop(xScale).ticks(WIDTH / 80, null);
const yAxis = d3.axisLeft(yScale).tickSizeOuter(0);
const svg = d3
.create("svg")
.attr("version", "1.1")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("viewBox", [0, 0, WIDTH, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg
.append("g")
.attr("transform", `translate(0,${margin.top})`)
.call(xAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll("text").text(d => formatX(d)))
.call(g =>
g
.selectAll(".tick line")
.clone()
.attr("y2", height - margin.top - margin.bottom)
.attr("stroke-opacity", 0.1)
);
const bar = svg
.append("g")
.attr("stroke", "#666")
.attr("stroke-width", 0.5)
.selectAll("g")
.data(series)
.join("g")
.attr("fill", d => colors[d.key])
.selectAll("rect")
.data(d => d.data.filter(([x1, x2]) => x1 !== x2))
.join("rect")
.attr("x", ([x1, x2]) => Math.min(xScale(x1), xScale(x2)))
.attr("y", ({i}) => yScale(Y[i]))
.attr("width", ([x1, x2]) => Math.abs(xScale(x1) - xScale(x2)))
.attr("height", yScale.bandwidth());
const totalZ = Object.fromEntries(
rollups(...[I, ([i]) => i, i => Y[i], i => X[i]]).map(([y, yz]) => [y, d3.sum(yz, yz => yz[0])])
);
const getTooltip = ({i}) => tooltip(Y[i], Z[i], X[i], X[i] / totalZ[Y[i]]);
bar.append("title").text(d => getTooltip(d).join("\r\n"));
bar.on("mouseover", d => tip(getTooltip(d).join(". ")));
svg
.append("g")
.attr("transform", `translate(${xScale(0)},0)`)
.call(yAxis);
const rowElements = Math.ceil(groups.length / legendRows);
const columnWidth = WIDTH / (rowElements + 0.5);
const ROW_HEIGHT = 20;
const getLegendX = (d, i) => (i % rowElements) * columnWidth;
const getLegendLabelX = (d, i) => getLegendX(d, i) + LABEL_GAP;
const getLegendY = (d, i) => Math.floor(i / rowElements) * ROW_HEIGHT;
const legend = svg
.append("g")
.attr("stroke", "#666")
.attr("stroke-width", 0.5)
.attr("dominant-baseline", "central")
.attr("transform", `translate(${margin.left},${height - margin.bottom + 15})`);
legend
.selectAll("circle")
.data(groups)
.join("rect")
.attr("x", getLegendX)
.attr("y", getLegendY)
.attr("width", 10)
.attr("height", 10)
.attr("transform", "translate(-5, -5)")
.attr("fill", d => colors[d]);
legend
.selectAll("text")
.data(groups)
.join("text")
.attr("x", getLegendLabelX)
.attr("y", getLegendY)
.text(d => d);
return svg.node();
}
function insertChart(id, $chart, title) {
const $chartContainer = byId("chartsOverview__charts");
const $figure = document.createElement("figure");
const $caption = document.createElement("figcaption");
const figureNo = $chartContainer.childElementCount + 1;
$caption.innerHTML = /* html */ `
<div>
<strong>Figure ${figureNo}</strong>. ${title}
</div>
<div>
<button data-tip="Download the chart in svg format (can open in browser or Inkscape)" class="icon-download"></button>
<button data-tip="Remove the chart" class="icon-trash"></button>
</div>
`;
$figure.appendChild($chart);
$figure.appendChild($caption);
$chartContainer.appendChild($figure);
const downloadChart = () => {
const name = `${getFileName(title)}.svg`;
downloadFile($chart.outerHTML, name);
};
const removeChart = () => {
$figure.remove();
charts = charts.filter(chart => chart.id !== id);
updateDialogPosition();
};
$figure.querySelector("button.icon-download").on("click", downloadChart);
$figure.querySelector("button.icon-trash").on("click", removeChart);
}
function changeViewColumns() {
const columns = byId("chartsOverview__viewColumns").value;
const $charts = byId("chartsOverview__charts");
$charts.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
updateDialogPosition();
}
function updateDialogPosition() {
$("#chartsOverview").dialog({position: {my: "center", at: "center", of: "svg"}});
}
function handleClose() {
const $chartContainer = byId("chartsOverview__charts");
$chartContainer.innerHTML = "";
$("#chartsOverview").dialog("destroy");
}
// config
const NEUTRAL_COLOR = "#ccc";
const EMPTY_NAME = "no";
const WIDTH = 800;
const Y_PADDING = 0.2;
const RESERVED_PX_PER_CHAR = 7;
const LABEL_GAP = 10;
function getTextMinWidth(entities) {
return d3.max(entities.map(name => name.length)) * RESERVED_PX_PER_CHAR;
}
function calculateLegendRows(groups, availableWidth) {
const minWidth = LABEL_GAP + getTextMinWidth(groups);
const maxInRow = Math.floor(availableWidth / minWidth);
const legendRows = Math.ceil(groups.length / maxInRow);
return legendRows;
}
function nameGetter(entity) {
return i => pack[entity][i].name || EMPTY_NAME;
}
function colorsGetter(entity) {
return () => Object.fromEntries(pack[entity].map(({name, color}) => [name || EMPTY_NAME, color || NEUTRAL_COLOR]));
}
function biomeNameGetter(i) {
return biomesData.name[i] || EMPTY_NAME;
}
function biomeColorsGetter() {
return Object.fromEntries(biomesData.i.map(i => [biomesData.name[i], biomesData.color[i]]));
}
function getUrbanPopulation(cellId) {
const burgId = pack.cells.burg[cellId];
if (!burgId) return 0;
const populationPoints = pack.burgs[burgId].population;
return populationPoints * populationRate * urbanization;
}
function getRuralPopulation(cellId) {
return pack.cells.pop[cellId] * populationRate;
}
function sortData(data, sorting) {
if (sorting === "natural") return data;
if (sorting === "name") {
return data.sort((a, b) => {
if (a.name !== b.name) return b.name.localeCompare(a.name); // reversed as 1st element is the bottom
return a.group.localeCompare(b.group);
});
}
if (sorting === "value") {
const entitySum = {};
const groupSum = {};
for (const {name, group, value} of data) {
entitySum[name] = (entitySum[name] || 0) + value;
groupSum[group] = (groupSum[group] || 0) + value;
}
return data.sort((a, b) => {
if (a.name !== b.name) return entitySum[a.name] - entitySum[b.name]; // reversed as 1st element is the bottom
return groupSum[b.group] - groupSum[a.group];
});
}
return data;
}

View file

@ -11,7 +11,7 @@ import {link} from "utils/linkUtils";
import {minmax, rn} from "utils/numberUtils";
import {regenerateMap} from "scripts/generation";
import {reMarkFeatures} from "modules/markup";
import {editUnits} from "modules/ui/editors";
import {closeDialogs} from "dialogs/utils";
// add drag to upload logic, pull request from @evyatron
export function addDragToUpload() {
@ -480,13 +480,6 @@ async function parseLoadedData(data) {
updatePresetInput();
})();
void (function restoreEvents() {
scaleBar.on("mousemove", () => tip("Click to open Units Editor")).on("click", editUnits);
legend
.on("mousemove", () => tip("Drag to change the position. Click to hide the legend"))
.on("click", () => clearLegend());
})();
{
// dynamically import and run auto-udpdate script
const versionNumber = parseFloat(params[0]);

View file

@ -2,6 +2,7 @@ import {tip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {ldb} from "scripts/indexedDB";
import {ra} from "utils/probabilityUtils";
import {closeDialogs} from "dialogs/utils";
// functions to save project as .map file

View file

@ -7,6 +7,7 @@ import {rn, minmax} from "utils/numberUtils";
import {rand, P, Pint} from "utils/probabilityUtils";
import {capitalize} from "utils/stringUtils";
import {getAdjective, list} from "utils/languageUtils";
import {closeDialogs} from "dialogs/utils";
export class Battle {
constructor(attacker, defender) {
@ -172,35 +173,38 @@ export class Battle {
const state = pack.states[regiment.state];
const distance = (Math.hypot(this.y - regiment.by, this.x - regiment.bx) * distanceScaleInput.value) | 0; // distance between regiment and its base
const color = state.color[0] === "#" ? state.color : "#999";
const icon = `<svg width="1.4em" height="1.4em" style="margin-bottom: -.6em; stroke: #333">
const icon = /* html */ `<svg width="1.4em" height="1.4em" style="margin-bottom: -.6em; stroke: #333">
<rect x="0" y="0" width="100%" height="100%" fill="${color}"></rect>
<text x="0" y="1.04em" style="">${regiment.icon}</text></svg>`;
const body = `<tbody id="battle${state.i}-${regiment.i}">`;
let initial = `<tr class="battleInitial"><td>${icon}</td><td class="regiment" data-tip="${
regiment.name
}">${regiment.name.slice(0, 24)}</td>`;
let casualties = `<tr class="battleCasualties"><td></td><td data-tip="${state.fullName}">${state.fullName.slice(
0,
26
)}</td>`;
let survivors = `<tr class="battleSurvivors"><td></td><td data-tip="Supply line length, affects morale">Distance to base: ${distance} ${distanceUnitInput.value}</td>`;
let initial = /* html */ `<tr class="battleInitial">
<td>${icon}</td>
<td class="regiment" data-tip="${regiment.name}">${regiment.name.slice(0, 24)}</td>
`;
let casualties = /* html */ `<tr class="battleCasualties">
<td></td>
<td data-tip="${state.fullName}">${state.fullName.slice(0, 26)}</td>
`;
let survivors = /* html */ `<tr class="battleSurvivors">
<td></td>
<td data-tip="Supply line length, affects morale">Distance to base: ${distance} ${distanceUnitInput.value}</td>
`;
for (const u of options.military) {
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${
regiment.u[u.name] || 0
}</td>`;
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">
${regiment.u[u.name] || 0}</td>`;
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
regiment.u[u.name] || 0
}</td>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">
${regiment.u[u.name] || 0}</td>`;
}
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${regiment.a || 0}</td></tr>`;
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td></tr>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
regiment.a || 0
}</td></tr>`;
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">
${regiment.a || 0}</td></tr>`;
const div = side === "attackers" ? battleAttackers : battleDefenders;
div.innerHTML += body + initial + casualties + survivors + "</tbody>";

View file

@ -1,486 +0,0 @@
import * as d3 from "d3";
import {restoreDefaultEvents} from "scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "scripts/tooltips";
import {getRandomColor} from "utils/colorUtils";
import {openURL} from "utils/linkUtils";
import {rn} from "utils/numberUtils";
import {si} from "utils/unitUtils";
export function editBiomes() {
if (customization) return;
closeDialogs("#biomesEditor, .stable");
if (!layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleReligions")) toggleReligions();
if (layerIsOn("toggleProvinces")) toggleProvinces();
const body = document.getElementById("biomesBody");
const animate = d3.transition().duration(2000).ease(d3.easeSinIn);
refreshBiomesEditor();
if (fmg.modules.editBiomes) return;
fmg.modules.editBiomes = true;
$("#biomesEditor").dialog({
title: "Biomes Editor",
resizable: false,
width: "fit-content",
close: closeBiomesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
// add listeners
document.getElementById("biomesEditorRefresh").addEventListener("click", refreshBiomesEditor);
document.getElementById("biomesEditStyle").addEventListener("click", () => editStyle("biomes"));
document.getElementById("biomesLegend").addEventListener("click", toggleLegend);
document.getElementById("biomesPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("biomesManually").addEventListener("click", enterBiomesCustomizationMode);
document.getElementById("biomesManuallyApply").addEventListener("click", applyBiomesChange);
document.getElementById("biomesManuallyCancel").addEventListener("click", () => exitBiomesCustomizationMode());
document.getElementById("biomesRestore").addEventListener("click", restoreInitialBiomes);
document.getElementById("biomesAdd").addEventListener("click", addCustomBiome);
document.getElementById("biomesRegenerateReliefIcons").addEventListener("click", regenerateIcons);
document.getElementById("biomesExport").addEventListener("click", downloadBiomesData);
body.addEventListener("click", function (ev) {
const el = ev.target;
const cl = el.classList;
if (el.tagName === "FILL-BOX") biomeChangeColor(el);
else if (cl.contains("icon-info-circled")) openWiki(el);
else if (cl.contains("icon-trash-empty")) removeCustomBiome(el);
if (customization === 6) selectBiomeOnLineClick(el);
});
body.addEventListener("change", function (ev) {
const el = ev.target,
cl = el.classList;
if (cl.contains("biomeName")) biomeChangeName(el);
else if (cl.contains("biomeHabitability")) biomeChangeHabitability(el);
});
function refreshBiomesEditor() {
biomesCollectStatistics();
biomesEditorAddLines();
}
function biomesCollectStatistics() {
const cells = pack.cells;
const array = new Uint8Array(biomesData.i.length);
biomesData.cells = Array.from(array);
biomesData.area = Array.from(array);
biomesData.rural = Array.from(array);
biomesData.urban = Array.from(array);
for (const i of cells.i) {
if (cells.h[i] < 20) continue;
const b = cells.biome[i];
biomesData.cells[b] += 1;
biomesData.area[b] += cells.area[i];
biomesData.rural[b] += cells.pop[i];
if (cells.burg[i]) biomesData.urban[b] += pack.burgs[cells.burg[i]].population;
}
}
function biomesEditorAddLines() {
const unit = " " + getAreaUnit();
const b = biomesData;
let lines = "",
totalArea = 0,
totalPopulation = 0;
for (const i of b.i) {
if (!i || biomesData.name[i] === "removed") continue; // ignore water and removed biomes
const area = getArea(b.area[i]);
const rural = b.rural[i] * populationRate;
const urban = b.urban[i] * populationRate * urbanization;
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}; Rural population: ${si(
rural
)}; Urban population: ${si(urban)}`;
totalArea += area;
totalPopulation += population;
lines += /* html */ `
<div
class="states biomes"
data-id="${i}"
data-name="${b.name[i]}"
data-habitability="${b.habitability[i]}"
data-cells=${b.cells[i]}
data-area=${area}
data-population=${population}
data-color=${b.color[i]}
>
<fill-box fill="${b.color[i]}"></fill-box>
<input data-tip="Biome name. Click and type to change" class="biomeName" value="${
b.name[i]
}" autocorrect="off" spellcheck="false" />
<span data-tip="Biome habitability percent" class="hide">%</span>
<input
data-tip="Biome habitability percent. Click and set new value to change"
type="number"
min="0"
max="9999"
class="biomeHabitability hide"
value=${b.habitability[i]}
/>
<span data-tip="Cells count" class="icon-check-empty hide"></span>
<div data-tip="Cells count" class="biomeCells hide">${b.cells[i]}</div>
<span data-tip="Biome area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Biome area" class="biomeArea hide">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="biomePopulation hide">${si(population)}</div>
<span data-tip="Open Wikipedia article about the biome" class="icon-info-circled pointer hide"></span>
${
i > 12 && !b.cells[i]
? '<span data-tip="Remove the custom biome" class="icon-trash-empty hide"></span>'
: ""
}
</div>
`;
}
body.innerHTML = lines;
// update footer
biomesFooterBiomes.innerHTML = body.querySelectorAll(":scope > div").length;
biomesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length;
biomesFooterArea.innerHTML = si(totalArea) + unit;
biomesFooterPopulation.innerHTML = si(totalPopulation);
biomesFooterArea.dataset.area = totalArea;
biomesFooterPopulation.dataset.population = totalPopulation;
// add listeners
body.querySelectorAll("div.biomes").forEach(el => el.addEventListener("mouseenter", ev => biomeHighlightOn(ev)));
body.querySelectorAll("div.biomes").forEach(el => el.addEventListener("mouseleave", ev => biomeHighlightOff(ev)));
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(biomesHeader);
$("#biomesEditor").dialog({width: "fit-content"});
}
function biomeHighlightOn(event) {
if (customization === 6) return;
const biome = +event.target.dataset.id;
biomes
.select("#biome" + biome)
.raise()
.transition(animate)
.attr("stroke-width", 2)
.attr("stroke", "#cd4c11");
}
function biomeHighlightOff(event) {
if (customization === 6) return;
const biome = +event.target.dataset.id;
const color = biomesData.color[biome];
biomes
.select("#biome" + biome)
.transition()
.attr("stroke-width", 0.7)
.attr("stroke", color);
}
function biomeChangeColor(el) {
const currentFill = el.getAttribute("fill");
const biome = +el.parentNode.dataset.id;
const callback = newFill => {
el.fill = newFill;
biomesData.color[biome] = newFill;
biomes
.select("#biome" + biome)
.attr("fill", newFill)
.attr("stroke", newFill);
};
openPicker(currentFill, callback);
}
function biomeChangeName(el) {
const biome = +el.parentNode.dataset.id;
el.parentNode.dataset.name = el.value;
biomesData.name[biome] = el.value;
}
function biomeChangeHabitability(el) {
const biome = +el.parentNode.dataset.id;
const failed = isNaN(+el.value) || +el.value < 0 || +el.value > 9999;
if (failed) {
el.value = biomesData.habitability[biome];
tip("Please provide a valid number in range 0-9999", false, "error");
return;
}
biomesData.habitability[biome] = +el.value;
el.parentNode.dataset.habitability = el.value;
recalculatePopulation();
refreshBiomesEditor();
}
function openWiki(el) {
const biomeName = el.parentNode.dataset.name;
if (biomeName === "Custom" || !biomeName) return tip("Please fill in the biome name", false, "error");
const wikiBase = "https://en.wikipedia.org/wiki/";
const pages = {
"Hot desert": "Desert_climate#Hot_desert_climates",
"Cold desert": "Desert_climate#Cold_desert_climates",
Savanna: "Tropical_and_subtropical_grasslands,_savannas,_and_shrublands",
Grassland: "Temperate_grasslands,_savannas,_and_shrublands",
"Tropical seasonal forest": "Seasonal_tropical_forest",
"Temperate deciduous forest": "Temperate_deciduous_forest",
"Tropical rainforest": "Tropical_rainforest",
"Temperate rainforest": "Temperate_rainforest",
Taiga: "Taiga",
Tundra: "Tundra",
Glacier: "Glacier",
Wetland: "Wetland"
};
const customBiomeLink = `https://en.wikipedia.org/w/index.php?search=${biomeName}`;
const link = pages[biomeName] ? wikiBase + pages[biomeName] : customBiomeLink;
openURL(link);
}
function toggleLegend() {
if (legend.selectAll("*").size()) {
clearLegend();
return;
} // hide legend
const d = biomesData;
const data = Array.from(d.i)
.filter(i => d.cells[i])
.sort((a, b) => d.area[b] - d.area[a])
.map(i => [i, d.color[i], d.name[i]]);
drawLegend("Biomes", data);
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = +biomesFooterCells.innerHTML;
const totalArea = +biomesFooterArea.dataset.area;
const totalPopulation = +biomesFooterPopulation.dataset.population;
body.querySelectorAll(":scope> div").forEach(function (el) {
el.querySelector(".biomeCells").innerHTML = rn((+el.dataset.cells / totalCells) * 100) + "%";
el.querySelector(".biomeArea").innerHTML = rn((+el.dataset.area / totalArea) * 100) + "%";
el.querySelector(".biomePopulation").innerHTML = rn((+el.dataset.population / totalPopulation) * 100) + "%";
});
} else {
body.dataset.type = "absolute";
biomesEditorAddLines();
}
}
function addCustomBiome() {
const b = biomesData,
i = biomesData.i.length;
if (i > 254) {
tip("Maximum number of biomes reached (255), data cleansing is required", false, "error");
return;
}
b.i.push(i);
b.color.push(getRandomColor());
b.habitability.push(50);
b.name.push("Custom");
b.iconsDensity.push(0);
b.icons.push([]);
b.cost.push(50);
b.rural.push(0);
b.urban.push(0);
b.cells.push(0);
b.area.push(0);
const unit = getAreaUnit();
const line = `<div class="states biomes" data-id="${i}" data-name="${b.name[i]}" data-habitability=${b.habitability[i]} data-cells=0 data-area=0 data-population=0 data-color=${b.color[i]}>
<fill-box fill="${b.color[i]}"></fill-box>
<input data-tip="Biome name. Click and type to change" class="biomeName" value="${b.name[i]}" autocorrect="off" spellcheck="false">
<span data-tip="Biome habitability percent" class="hide">%</span>
<input data-tip="Biome habitability percent. Click and set new value to change" type="number" min=0 max=9999 step=1 class="biomeHabitability hide" value=${b.habitability[i]}>
<span data-tip="Cells count" class="icon-check-empty hide"></span>
<div data-tip="Cells count" class="biomeCells hide">${b.cells[i]}</div>
<span data-tip="Biome area" style="padding-right: 4px" class="icon-map-o hide"></span>
<div data-tip="Biome area" class="biomeArea hide">0 ${unit}</div>
<span data-tip="Total population: 0" class="icon-male hide"></span>
<div data-tip="Total population: 0" class="biomePopulation hide">0</div>
<span data-tip="Remove the custom biome" class="icon-trash-empty hide"></span>
</div>`;
body.insertAdjacentHTML("beforeend", line);
biomesFooterBiomes.innerHTML = body.querySelectorAll(":scope > div").length;
$("#biomesEditor").dialog({width: "fit-content"});
}
function removeCustomBiome(el) {
const biome = +el.parentNode.dataset.id;
el.parentNode.remove();
biomesData.name[biome] = "removed";
biomesFooterBiomes.innerHTML = +biomesFooterBiomes.innerHTML - 1;
}
function regenerateIcons() {
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
}
function downloadBiomesData() {
const unit = areaUnit.value === "square" ? distanceUnitInput.value + "2" : areaUnit.value;
let data = "Id,Biome,Color,Habitability,Cells,Area " + unit + ",Population\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
data += el.dataset.id + ",";
data += el.dataset.name + ",";
data += el.dataset.color + ",";
data += el.dataset.habitability + "%,";
data += el.dataset.cells + ",";
data += el.dataset.area + ",";
data += el.dataset.population + "\n";
});
const name = getFileName("Biomes") + ".csv";
downloadFile(data, name);
}
function enterBiomesCustomizationMode() {
if (!layerIsOn("toggleBiomes")) toggleBiomes();
customization = 6;
biomes.append("g").attr("id", "temp");
document.querySelectorAll("#biomesBottom > button").forEach(el => (el.style.display = "none"));
document.querySelectorAll("#biomesBottom > div").forEach(el => (el.style.display = "block"));
body.querySelector("div.biomes").classList.add("selected");
biomesEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
biomesFooter.style.display = "none";
$("#biomesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
tip("Click on biome to select, drag the circle to change biome", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectBiomeOnMapClick)
.call(d3.drag().on("start", dragBiomeBrush))
.on("touchmove mousemove", moveBiomeBrush);
}
function selectBiomeOnLineClick(line) {
const selected = body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
line.classList.add("selected");
}
function selectBiomeOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) {
tip("You cannot reassign water via biomes. Please edit the Heightmap to change water", false, "error");
return;
}
const assigned = biomes.select("#temp").select("polygon[data-cell='" + i + "']");
const biome = assigned.size() ? +assigned.attr("data-biome") : pack.cells.biome[i];
body.querySelector("div.selected").classList.remove("selected");
body.querySelector("div[data-id='" + biome + "']").classList.add("selected");
}
function dragBiomeBrush() {
const r = +biomesManuallyBrush.value;
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
const selection = found.filter(isLand);
if (selection) changeBiomeForSelection(selection);
});
}
// change region within selection
function changeBiomeForSelection(selection) {
const temp = biomes.select("#temp");
const selected = body.querySelector("div.selected");
const biomeNew = selected.dataset.id;
const color = biomesData.color[biomeNew];
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const biomeOld = exists.size() ? +exists.attr("data-biome") : pack.cells.biome[i];
if (biomeNew === biomeOld) return;
// change of append new element
if (exists.size()) exists.attr("data-biome", biomeNew).attr("fill", color).attr("stroke", color);
else
temp
.append("polygon")
.attr("data-cell", i)
.attr("data-biome", biomeNew)
.attr("points", getPackPolygon(i))
.attr("fill", color)
.attr("stroke", color);
});
}
function moveBiomeBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +biomesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyBiomesChange() {
const changed = biomes.select("#temp").selectAll("polygon");
changed.each(function () {
const i = +this.dataset.cell;
const b = +this.dataset.biome;
pack.cells.biome[i] = b;
});
if (changed.size()) {
drawBiomes();
refreshBiomesEditor();
}
exitBiomesCustomizationMode();
}
function exitBiomesCustomizationMode(close) {
customization = 0;
biomes.select("#temp").remove();
removeCircle();
document.querySelectorAll("#biomesBottom > button").forEach(el => (el.style.display = "inline-block"));
document.querySelectorAll("#biomesBottom > div").forEach(el => (el.style.display = "none"));
body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
biomesEditor.querySelectorAll(".hide").forEach(el => el.classList.remove("hidden"));
biomesFooter.style.display = "block";
if (!close) $("#biomesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
restoreDefaultEvents();
clearMainTip();
const selected = document.querySelector("#biomesBody > div.selected");
if (selected) selected.classList.remove("selected");
}
function restoreInitialBiomes() {
biomesData = applyDefaultBiomesSystem();
Biomes.define();
drawBiomes();
recalculatePopulation();
refreshBiomesEditor();
}
function closeBiomesEditor() {
exitBiomesCustomizationMode("close");
}
}

View file

@ -1,595 +0,0 @@
import * as d3 from "d3";
import {findCell} from "utils/graphUtils";
import {tip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {prompt} from "scripts/prompt";
import {rand} from "utils/probabilityUtils";
import {parseTransform} from "utils/stringUtils";
import {getHeight} from "utils/unitUtils";
export function editBurg(id) {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleIcons")) toggleIcons();
if (!layerIsOn("toggleLabels")) toggleLabels();
const burg = id || d3.event.target.dataset.id;
elSelected = burgLabels.select("[data-id='" + burg + "']");
burgLabels.selectAll("text").call(d3.drag().on("start", dragBurgLabel)).classed("draggable", true);
updateBurgValues();
$("#burgEditor").dialog({
title: "Edit Burg",
resizable: false,
close: closeBurgEditor,
position: {my: "left top", at: "left+10 top+10", of: "svg", collision: "fit"}
});
if (fmg.modules.editBurg) return;
fmg.modules.editBurg = true;
// add listeners
document.getElementById("burgGroupShow").addEventListener("click", showGroupSection);
document.getElementById("burgGroupHide").addEventListener("click", hideGroupSection);
document.getElementById("burgSelectGroup").addEventListener("change", changeGroup);
document.getElementById("burgInputGroup").addEventListener("change", createNewGroup);
document.getElementById("burgAddGroup").addEventListener("click", toggleNewGroupInput);
document.getElementById("burgRemoveGroup").addEventListener("click", removeBurgsGroup);
document.getElementById("burgName").addEventListener("input", changeName);
document.getElementById("burgNameReRandom").addEventListener("click", generateNameRandom);
document.getElementById("burgType").addEventListener("input", changeType);
document.getElementById("burgCulture").addEventListener("input", changeCulture);
document.getElementById("burgNameReCulture").addEventListener("click", generateNameCulture);
document.getElementById("burgPopulation").addEventListener("change", changePopulation);
burgBody.querySelectorAll(".burgFeature").forEach(el => el.addEventListener("click", toggleFeature));
document.getElementById("mfcgBurgSeed").addEventListener("change", changeSeed);
document.getElementById("regenerateMFCGBurgSeed").addEventListener("click", randomizeSeed);
document.getElementById("addCustomMFCGBurgLink").addEventListener("click", addCustomMfcgLink);
document.getElementById("burgStyleShow").addEventListener("click", showStyleSection);
document.getElementById("burgStyleHide").addEventListener("click", hideStyleSection);
document.getElementById("burgEditLabelStyle").addEventListener("click", editGroupLabelStyle);
document.getElementById("burgEditIconStyle").addEventListener("click", editGroupIconStyle);
document.getElementById("burgEditAnchorStyle").addEventListener("click", editGroupAnchorStyle);
document.getElementById("burgEmblem").addEventListener("click", openEmblemEdit);
document.getElementById("burgToggleMFCGMap").addEventListener("click", toggleMFCGMap);
document.getElementById("burgEditEmblem").addEventListener("click", openEmblemEdit);
document.getElementById("burgRelocate").addEventListener("click", toggleRelocateBurg);
document.getElementById("burglLegend").addEventListener("click", editBurgLegend);
document.getElementById("burgLock").addEventListener("click", toggleBurgLockButton);
document.getElementById("burgRemove").addEventListener("click", removeSelectedBurg);
document.getElementById("burgTemperatureGraph").addEventListener("click", showTemperatureGraph);
function updateBurgValues() {
const id = +elSelected.attr("data-id");
const b = pack.burgs[id];
const province = pack.cells.province[b.cell];
const provinceName = province ? pack.provinces[province].fullName + ", " : "";
const stateName = pack.states[b.state].fullName || pack.states[b.state].name;
document.getElementById("burgProvinceAndState").innerHTML = provinceName + stateName;
document.getElementById("burgName").value = b.name;
document.getElementById("burgType").value = b.type || "Generic";
document.getElementById("burgPopulation").value = rn(b.population * populationRate * urbanization);
document.getElementById("burgEditAnchorStyle").style.display = +b.port ? "inline-block" : "none";
// update list and select culture
const cultureSelect = document.getElementById("burgCulture");
cultureSelect.options.length = 0;
const cultures = pack.cultures.filter(c => !c.removed);
cultures.forEach(c => cultureSelect.options.add(new Option(c.name, c.i, false, c.i === b.culture)));
const temperature = grid.cells.temp[pack.cells.g[b.cell]];
document.getElementById("burgTemperature").innerHTML = convertTemperature(temperature);
document.getElementById("burgTemperatureLikeIn").innerHTML = getTemperatureLikeness(temperature);
document.getElementById("burgElevation").innerHTML = getHeight(pack.cells.h[b.cell]);
// toggle features
if (b.capital) document.getElementById("burgCapital").classList.remove("inactive");
else document.getElementById("burgCapital").classList.add("inactive");
if (b.port) document.getElementById("burgPort").classList.remove("inactive");
else document.getElementById("burgPort").classList.add("inactive");
if (b.citadel) document.getElementById("burgCitadel").classList.remove("inactive");
else document.getElementById("burgCitadel").classList.add("inactive");
if (b.walls) document.getElementById("burgWalls").classList.remove("inactive");
else document.getElementById("burgWalls").classList.add("inactive");
if (b.plaza) document.getElementById("burgPlaza").classList.remove("inactive");
else document.getElementById("burgPlaza").classList.add("inactive");
if (b.temple) document.getElementById("burgTemple").classList.remove("inactive");
else document.getElementById("burgTemple").classList.add("inactive");
if (b.shanty) document.getElementById("burgShanty").classList.remove("inactive");
else document.getElementById("burgShanty").classList.add("inactive");
//toggle lock
updateBurgLockIcon();
// select group
const group = elSelected.node().parentNode.id;
const select = document.getElementById("burgSelectGroup");
select.options.length = 0; // remove all options
burgLabels.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
// set emlem image
const coaID = "burgCOA" + id;
COArenderer.trigger(coaID, b.coa);
document.getElementById("burgEmblem").setAttribute("href", "#" + coaID);
if (options.showMFCGMap) {
document.getElementById("mfcgPreviewSection").style.display = "block";
updateMFCGFrame(b);
if (b.link) {
document.getElementById("mfcgBurgSeedSection").style.display = "none";
} else {
document.getElementById("mfcgBurgSeedSection").style.display = "inline-block";
document.getElementById("mfcgBurgSeed").value = getBurgSeed(b);
}
} else {
document.getElementById("mfcgPreviewSection").style.display = "none";
}
}
// in °C, array from -1 °C; source: https://en.wikipedia.org/wiki/List_of_cities_by_average_temperature
function getTemperatureLikeness(temperature) {
if (temperature < -5) return "Yakutsk";
const cities = [
"Snag (Yukon)",
"Yellowknife (Canada)",
"Okhotsk (Russia)",
"Fairbanks (Alaska)",
"Nuuk (Greenland)",
"Murmansk", // -5 - 0
"Arkhangelsk",
"Anchorage",
"Tromsø",
"Reykjavik",
"Riga",
"Stockholm",
"Halifax",
"Prague",
"Copenhagen",
"London", // 1 - 10
"Antwerp",
"Paris",
"Milan",
"Batumi",
"Rome",
"Dubrovnik",
"Lisbon",
"Barcelona",
"Marrakesh",
"Alexandria", // 11 - 20
"Tegucigalpa",
"Guangzhou",
"Rio de Janeiro",
"Dakar",
"Miami",
"Jakarta",
"Mogadishu",
"Bangkok",
"Aden",
"Khartoum"
]; // 21 - 30
if (temperature > 30) return "Mecca";
return cities[temperature + 5] || null;
}
function dragBurgLabel() {
const tr = parseTransform(this.getAttribute("transform"));
const dx = +tr[0] - d3.event.x,
dy = +tr[1] - d3.event.y;
d3.event.on("drag", function () {
const x = d3.event.x,
y = d3.event.y;
this.setAttribute("transform", `translate(${dx + x},${dy + y})`);
tip('Use dragging for fine-tuning only, to actually move burg use "Relocate" button', false, "warning");
});
}
function showGroupSection() {
document.querySelectorAll("#burgBottom > button").forEach(el => (el.style.display = "none"));
document.getElementById("burgGroupSection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#burgBottom > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("burgGroupSection").style.display = "none";
document.getElementById("burgInputGroup").style.display = "none";
document.getElementById("burgInputGroup").value = "";
document.getElementById("burgSelectGroup").style.display = "inline-block";
}
function changeGroup() {
const id = +elSelected.attr("data-id");
moveBurgToGroup(id, this.value);
}
function toggleNewGroupInput() {
if (burgInputGroup.style.display === "none") {
burgInputGroup.style.display = "inline-block";
burgInputGroup.focus();
burgSelectGroup.style.display = "none";
} else {
burgInputGroup.style.display = "none";
burgSelectGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name", false, "error");
return;
}
const group = this.value
.toLowerCase()
.replace(/ /g, "_")
.replace(/[^\w\s]/gi, "");
if (document.getElementById(group)) {
tip("Element with this id already exists. Please provide a unique name", false, "error");
return;
}
if (Number.isFinite(+group.charAt(0))) {
tip("Group name should start with a letter", false, "error");
return;
}
const id = +elSelected.attr("data-id");
const oldGroup = elSelected.node().parentNode.id;
const label = document.querySelector("#burgLabels [data-id='" + id + "']");
const icon = document.querySelector("#burgIcons [data-id='" + id + "']");
const anchor = document.querySelector("#anchors [data-id='" + id + "']");
if (!label || !icon) {
ERROR && console.error("Cannot find label or icon elements");
return;
}
const labelG = document.querySelector("#burgLabels > #" + oldGroup);
const iconG = document.querySelector("#burgIcons > #" + oldGroup);
const anchorG = document.querySelector("#anchors > #" + oldGroup);
// just rename if only 1 element left
const count = elSelected.node().parentNode.childElementCount;
if (oldGroup !== "cities" && oldGroup !== "towns" && count === 1) {
document.getElementById("burgSelectGroup").selectedOptions[0].remove();
document.getElementById("burgSelectGroup").options.add(new Option(group, group, false, true));
toggleNewGroupInput();
document.getElementById("burgInputGroup").value = "";
labelG.id = group;
iconG.id = group;
if (anchor) anchorG.id = group;
return;
}
// create new groups
document.getElementById("burgSelectGroup").options.add(new Option(group, group, false, true));
toggleNewGroupInput();
document.getElementById("burgInputGroup").value = "";
addBurgsGroup(group);
moveBurgToGroup(id, group);
}
function removeBurgsGroup() {
const group = elSelected.node().parentNode;
const basic = group.id === "cities" || group.id === "towns";
const burgsInGroup = [];
for (let i = 0; i < group.children.length; i++) {
burgsInGroup.push(+group.children[i].dataset.id);
}
const burgsToRemove = burgsInGroup.filter(b => !(pack.burgs[b].capital || pack.burgs[b].lock));
const capital = burgsToRemove.length < burgsInGroup.length;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
basic || capital ? "all unlocked elements in the burg group" : "the entire burg group"
}?
<br />Please note that capital or locked burgs will not be deleted. <br /><br />Burgs to be removed: ${
burgsToRemove.length
}`;
$("#alert").dialog({
resizable: false,
title: "Remove burg group",
buttons: {
Remove: function () {
$(this).dialog("close");
$("#burgEditor").dialog("close");
hideGroupSection();
burgsToRemove.forEach(b => removeBurg(b));
if (!basic && !capital) {
// entirely remove group
const labelG = document.querySelector("#burgLabels > #" + group.id);
const iconG = document.querySelector("#burgIcons > #" + group.id);
const anchorG = document.querySelector("#anchors > #" + group.id);
if (labelG) labelG.remove();
if (iconG) iconG.remove();
if (anchorG) anchorG.remove();
}
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function changeName() {
const id = +elSelected.attr("data-id");
pack.burgs[id].name = burgName.value;
elSelected.text(burgName.value);
}
function generateNameRandom() {
const base = rand(nameBases.length - 1);
burgName.value = Names.getBase(base);
changeName();
}
function changeType() {
const id = +elSelected.attr("data-id");
pack.burgs[id].type = this.value;
}
function changeCulture() {
const id = +elSelected.attr("data-id");
pack.burgs[id].culture = +this.value;
}
function generateNameCulture() {
const id = +elSelected.attr("data-id");
const culture = pack.burgs[id].culture;
burgName.value = Names.getCulture(culture);
changeName();
}
function changePopulation() {
const id = +elSelected.attr("data-id");
pack.burgs[id].population = rn(burgPopulation.value / populationRate / urbanization, 4);
}
function toggleFeature() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
const feature = this.dataset.feature;
const turnOn = this.classList.contains("inactive");
if (feature === "port") togglePort(id);
else if (feature === "capital") toggleCapital(id);
else burg[feature] = +turnOn;
if (burg[feature]) this.classList.remove("inactive");
else if (!burg[feature]) this.classList.add("inactive");
if (burg.port) document.getElementById("burgEditAnchorStyle").style.display = "inline-block";
else document.getElementById("burgEditAnchorStyle").style.display = "none";
updateMFCGFrame(burg);
}
function toggleBurgLockButton() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
burg.lock = !burg.lock;
updateBurgLockIcon();
}
function updateBurgLockIcon() {
const id = +elSelected.attr("data-id");
const b = pack.burgs[id];
if (b.lock) {
document.getElementById("burgLock").classList.remove("icon-lock-open");
document.getElementById("burgLock").classList.add("icon-lock");
} else {
document.getElementById("burgLock").classList.remove("icon-lock");
document.getElementById("burgLock").classList.add("icon-lock-open");
}
}
function showStyleSection() {
document.querySelectorAll("#burgBottom > button").forEach(el => (el.style.display = "none"));
document.getElementById("burgStyleSection").style.display = "inline-block";
}
function hideStyleSection() {
document.querySelectorAll("#burgBottom > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("burgStyleSection").style.display = "none";
}
function editGroupLabelStyle() {
const g = elSelected.node().parentNode.id;
editStyle("labels", g);
}
function editGroupIconStyle() {
const g = elSelected.node().parentNode.id;
editStyle("burgIcons", g);
}
function editGroupAnchorStyle() {
const g = elSelected.node().parentNode.id;
editStyle("anchors", g);
}
function updateMFCGFrame(burg) {
const mfcgURL = getMFCGlink(burg);
document.getElementById("mfcgPreview").setAttribute("src", mfcgURL + "&preview=1");
document.getElementById("mfcgLink").setAttribute("href", mfcgURL);
}
function changeSeed() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
const burgSeed = +this.value;
burg.MFCG = burgSeed;
updateMFCGFrame(burg);
}
function randomizeSeed() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
const burgSeed = rand(1e9 - 1);
burg.MFCG = burgSeed;
updateMFCGFrame(burg);
document.getElementById("mfcgBurgSeed").value = burgSeed;
}
function addCustomMfcgLink() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
const message =
"Enter custom link to the burg map. It can be a link to Medieval Fantasy City Generator or other tool. Keep empty to use MFCG seed";
prompt(message, {default: burg.link || "", required: false}, link => {
if (link) burg.link = link;
else delete burg.link;
updateMFCGFrame(burg);
});
}
function openEmblemEdit() {
const id = +elSelected.attr("data-id"),
burg = pack.burgs[id];
editEmblem("burg", "burgCOA" + id, burg);
}
function toggleMFCGMap() {
options.showMFCGMap = !options.showMFCGMap;
document.getElementById("mfcgPreviewSection").style.display = options.showMFCGMap ? "block" : "none";
document.getElementById("burgToggleMFCGMap").className = options.showMFCGMap ? "icon-map" : "icon-map-o";
}
function toggleRelocateBurg() {
const toggler = document.getElementById("toggleCells");
document.getElementById("burgRelocate").classList.toggle("pressed");
if (document.getElementById("burgRelocate").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", relocateBurgOnClick);
tip("Click on map to relocate burg. Hold Shift for continuous move", true);
if (!layerIsOn("toggleCells")) {
toggleCells();
toggler.dataset.forced = true;
}
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
if (layerIsOn("toggleCells") && toggler.dataset.forced) {
toggleCells();
toggler.dataset.forced = false;
}
}
}
function relocateBurgOnClick() {
const cells = pack.cells;
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
if (cells.h[cell] < 20) {
tip("Cannot place burg into the water! Select a land cell", false, "error");
return;
}
if (cells.burg[cell] && cells.burg[cell] !== id) {
tip("There is already a burg in this cell. Please select a free cell", false, "error");
return;
}
const newState = cells.state[cell];
const oldState = burg.state;
if (newState !== oldState && burg.capital) {
tip("Capital cannot be relocated into another state!", false, "error");
return;
}
// change UI
const x = rn(point[0], 2),
y = rn(point[1], 2);
burgIcons
.select("[data-id='" + id + "']")
.attr("transform", null)
.attr("cx", x)
.attr("cy", y);
burgLabels
.select("text[data-id='" + id + "']")
.attr("transform", null)
.attr("x", x)
.attr("y", y);
const anchor = anchors.select("use[data-id='" + id + "']");
if (anchor.size()) {
const size = anchor.attr("width");
const xa = rn(x - size * 0.47, 2);
const ya = rn(y - size * 0.47, 2);
anchor.attr("transform", null).attr("x", xa).attr("y", ya);
}
// change data
cells.burg[burg.cell] = 0;
cells.burg[cell] = id;
burg.cell = cell;
burg.state = newState;
burg.x = x;
burg.y = y;
if (burg.capital) pack.states[newState].center = burg.cell;
if (d3.event.shiftKey === false) toggleRelocateBurg();
}
function editBurgLegend() {
const id = elSelected.attr("data-id");
const name = elSelected.text();
editNotes("burg" + id, name);
}
function showTemperatureGraph() {
const id = elSelected.attr("data-id");
showBurgTemperatureGraph(id);
}
function removeSelectedBurg() {
const id = +elSelected.attr("data-id");
if (pack.burgs[id].capital) {
alertMessage.innerHTML = /* html */ `You cannot remove the burg as it is a state capital.<br /><br />
You can change the capital using Burgs Editor (shift + T)`;
$("#alert").dialog({
resizable: false,
title: "Remove burg",
buttons: {
Ok: function () {
$(this).dialog("close");
}
}
});
} else {
alertMessage.innerHTML = "Are you sure you want to remove the burg?";
$("#alert").dialog({
resizable: false,
title: "Remove burg",
buttons: {
Remove: function () {
$(this).dialog("close");
removeBurg(id); // see Editors module
$("#burgEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
}
function closeBurgEditor() {
document.getElementById("burgRelocate").classList.remove("pressed");
burgLabels.selectAll("text").call(d3.drag().on("drag", null)).classed("draggable", false);
unselect();
}
}

View file

@ -7,6 +7,8 @@ import {getCoordinates} from "utils/coordinateUtils";
import {rn} from "utils/numberUtils";
import {si, siToInteger} from "utils/unitUtils";
import {getHeight} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
import {openDialog} from "dialogs";
export function overviewBurgs() {
if (customization) return;
@ -241,8 +243,7 @@ export function overviewBurgs() {
}
function openBurgEditor() {
const burg = +this.parentNode.dataset.id;
editBurg(burg);
openDialog("burgEditor", null, {id: +this.parentNode.dataset.id});
}
function triggerBurgRemove() {

View file

@ -6,6 +6,7 @@ import {clipPoly} from "utils/lineUtils";
import {rn} from "utils/numberUtils";
import {round} from "utils/stringUtils";
import {si} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
export function editCoastline(node = d3.event.target) {
if (customization) return;

View file

@ -3,6 +3,7 @@ import * as d3 from "d3";
import {restoreDefaultEvents} from "scripts/events";
import {findCell} from "utils/graphUtils";
import {tip, clearMainTip} from "scripts/tooltips";
import {closeDialogs} from "dialogs/utils";
export function editDiplomacy() {
if (customization) return;

View file

@ -6,6 +6,7 @@ import {byId} from "utils/shorthands";
import {tip} from "scripts/tooltips";
import {rn, minmax, normalize} from "utils/numberUtils";
import {parseTransform} from "utils/stringUtils";
import {each} from "utils/probabilityUtils";
// clear elSelected variable
export function unselect() {
@ -17,17 +18,6 @@ export function unselect() {
elSelected = null;
}
// close all dialogs except stated
export function closeDialogs(except = "#except") {
try {
$(".dialog:visible")
.not(except)
.each(function () {
$(this).dialog("close");
});
} catch (error) {}
}
// move brush radius circle
export function moveCircle(x, y, r = 20) {
let circle = byId("brushCircle");
@ -256,11 +246,11 @@ function togglePort(burg) {
.attr("height", size);
}
function getBurgSeed(burg) {
export function getBurgSeed(burg) {
return burg.MFCG || Number(`${seed}${String(burg.i).padStart(4, 0)}`);
}
function getMFCGlink(burg) {
export function getMFCGlink(burg) {
if (burg.link) return burg.link;
const {cells} = pack;
@ -1025,27 +1015,3 @@ function refreshAllEditors() {
if (byId("zonesEditorRefresh")?.offsetParent) zonesEditorRefresh.click();
TIME && console.timeEnd("refreshAllEditors");
}
// dynamically loaded editors
export async function editStates() {
if (customization) return;
const Editor = await import("../dynamic/editors/states-editor.js");
Editor.open();
}
export async function editCultures() {
if (customization) return;
const Editor = await import("../dynamic/editors/cultures-editor.js");
Editor.open();
}
export async function editReligions() {
if (customization) return;
const Editor = await import("../dynamic/editors/religions-editor.js");
Editor.open();
}
export async function editUnits() {
const {open} = await import("./units-editor.js");
open();
}

View file

@ -16,6 +16,7 @@ import {prompt} from "scripts/prompt";
import {clearMainTip, showMainTip, tip} from "scripts/tooltips";
import {aleaPRNG} from "scripts/aleaPRNG";
import {undraw} from "scripts/generation";
import {closeDialogs} from "dialogs/utils";
export function editHeightmap(options) {
const {mode, tool} = options || {};

View file

@ -1,6 +1,8 @@
import {byId} from "utils/shorthands";
import {openDialog} from "dialogs";
import {toggleLayer} from "layers";
import {showAboutDialog} from "scripts/options/about";
import {byId} from "utils/shorthands";
import {closeDialogs} from "dialogs/utils";
// Hotkeys, see github.com/Azgaar/Fantasy-Map-Generator/wiki/Hotkeys
document.on("keydown", handleKeydown);
@ -40,17 +42,17 @@ function handleKeyup(event) {
else if (ctrl && code === "KeyY" && redo?.offsetParent) redo.click();
else if (shift && code === "KeyH") editHeightmap();
else if (shift && code === "KeyB") editBiomes();
else if (shift && code === "KeyS") editStates();
else if (shift && code === "KeyS") openDialog("statesEditor");
else if (shift && code === "KeyP") editProvinces();
else if (shift && code === "KeyD") editDiplomacy();
else if (shift && code === "KeyC") editCultures();
else if (shift && code === "KeyC") openDialog("culturesEditor");
else if (shift && code === "KeyN") editNamesbase();
else if (shift && code === "KeyZ") editZones();
else if (shift && code === "KeyR") editReligions();
else if (shift && code === "KeyR") openDialog("religionsEditor");
else if (shift && code === "KeyY") openEmblemEditor();
else if (shift && code === "KeyQ") editUnits();
else if (shift && code === "KeyQ") openDialog("unitsEditor");
else if (shift && code === "KeyO") editNotes();
else if (shift && code === "KeyA") overviewCharts();
else if (shift && code === "KeyA") openDialog("chartsOverview");
else if (shift && code === "KeyT") overviewBurgs();
else if (shift && code === "KeyV") overviewRivers();
else if (shift && code === "KeyM") overviewMilitary();

View file

@ -5,6 +5,7 @@ import {tip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {ra} from "utils/probabilityUtils";
import {parseTransform} from "utils/stringUtils";
import {closeDialogs} from "dialogs/utils";
export function editIce() {
if (customization) return;

View file

@ -1,6 +1,7 @@
import {findCell} from "utils/graphUtils";
import {tip, showMainTip} from "scripts/tooltips";
import {round, parseTransform} from "utils/stringUtils";
import {closeDialogs} from "dialogs/utils";
export function editLabel() {
if (customization) return;

View file

@ -6,6 +6,7 @@ import {rn} from "utils/numberUtils";
import {rand} from "utils/probabilityUtils";
import {round} from "utils/stringUtils";
import {si, getHeight} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
export function editLake() {
if (customization) return;

View file

@ -4,6 +4,7 @@ import {restoreDefaultEvents} from "scripts/events";
import {findCell} from "utils/graphUtils";
import {clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {closeDialogs} from "dialogs/utils";
export function editMarker(markerI) {
if (customization) return;

View file

@ -1,5 +1,6 @@
import {restoreDefaultEvents} from "scripts/events";
import {clearMainTip} from "scripts/tooltips";
import {closeDialogs} from "dialogs/utils";
export function overviewMarkers() {
if (customization) return;

View file

@ -5,6 +5,7 @@ import {wiki} from "utils/linkUtils";
import {rn} from "utils/numberUtils";
import {capitalize} from "utils/stringUtils";
import {si} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
export function overviewMilitary() {
if (customization) return;

View file

@ -4,6 +4,7 @@ import {unique} from "utils/arrayUtils";
import {tip} from "scripts/tooltips";
import {openURL} from "utils/linkUtils";
import {rn} from "utils/numberUtils";
import {closeDialogs} from "dialogs/utils";
export function editNamesbase() {
if (customization) return;

View file

@ -11,6 +11,8 @@ import {gauss, P, rand, rw} from "utils/probabilityUtils";
import {byId, stored} from "utils/shorthands";
import {regenerateMap} from "scripts/generation";
import {fitScaleBar} from "modules/measurers";
import {openDialog} from "dialogs";
import {closeDialogs} from "dialogs/utils";
$("#optionsContainer").draggable({handle: ".drag-trigger", snap: "svg", snapMode: "both"});
$("#exitCustomization").draggable({handle: "div"});
@ -160,7 +162,7 @@ optionsContent.addEventListener("click", function (event) {
else if (id === "optionsMapHistory") showSeedHistoryDialog();
else if (id === "optionsCopySeed") copyMapURL();
else if (id === "optionsEraRegenerate") regenerateEra();
else if (id === "templateInputContainer") openTemplateSelectionDialog();
else if (id === "templateInputContainer") openDialog("heightmapSelection");
else if (id === "zoomExtentDefault") restoreDefaultZoomExtent();
else if (id === "translateExtent") toggleTranslateExtent(event.target);
else if (id === "speakerTest") testSpeaker();
@ -650,11 +652,6 @@ function changeEra() {
options.era = eraInput.value;
}
async function openTemplateSelectionDialog() {
const HeightmapSelectionDialog = await import("../dynamic/heightmap-selection.js");
HeightmapSelectionDialog.open();
}
// remove all saved data from LocalStorage and reload the page
function restoreDefaultOptions() {
localStorage.clear();

View file

@ -11,6 +11,7 @@ import {parseTransform} from "utils/stringUtils";
import {si} from "utils/unitUtils";
import {turnLayerButtonOff} from "layers";
import {byId} from "utils/shorthands";
import {closeDialogs} from "dialogs/utils";
export function editProvinces() {
if (customization) return;
@ -387,7 +388,7 @@ export function editProvinces() {
unfog();
closeDialogs();
editStates();
openDialog("statesEditor");
}
function changePopulation(province) {

View file

@ -6,6 +6,7 @@ import {last} from "utils/arrayUtils";
import {tip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {capitalize} from "utils/stringUtils";
import {closeDialogs} from "dialogs/utils";
export function editRegiment(selector) {
if (customization) return;

View file

@ -6,6 +6,7 @@ import {tip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {capitalize} from "utils/stringUtils";
import {si} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
export function overviewRegiments(state) {
if (customization) return;

View file

@ -4,6 +4,7 @@ import {restoreDefaultEvents} from "scripts/events";
import {findCell} from "utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {closeDialogs} from "dialogs/utils";
export function editReliefIcon() {
if (customization) return;

View file

@ -5,6 +5,7 @@ import {getPackPolygon, findCell} from "utils/graphUtils";
import {last} from "utils/arrayUtils";
import {tip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {closeDialogs} from "dialogs/utils";
export function createRiver() {
if (customization) return;

View file

@ -5,6 +5,7 @@ import {tip, clearMainTip} from "scripts/tooltips";
import {getSegmentId} from "utils/lineUtils";
import {rn} from "utils/numberUtils";
import {rand} from "utils/probabilityUtils";
import {closeDialogs} from "dialogs/utils";
export function editRiver(id) {
if (customization) return;

View file

@ -1,6 +1,7 @@
import * as d3 from "d3";
import {rn} from "utils/numberUtils";
import {closeDialogs} from "dialogs/utils";
export function overviewRivers() {
if (customization) return;

View file

@ -5,6 +5,7 @@ import {getSegmentId} from "utils/lineUtils";
import {rn} from "utils/numberUtils";
import {getNextId} from "utils/nodeUtils";
import {round} from "utils/stringUtils";
import {closeDialogs} from "dialogs/utils";
export function editRoute(onClick) {
if (customization) return;

View file

@ -5,6 +5,7 @@ import {rn, minmax} from "utils/numberUtils";
import {debounce} from "utils/functionUtils";
import {restoreLayers} from "layers";
import {undraw} from "scripts/generation";
import {closeDialogs} from "dialogs/utils";
window.UISubmap = (function () {
byId("submapPointsInput").addEventListener("input", function () {

View file

@ -1,7 +1,8 @@
import * as d3 from "d3";
import {openDialog} from "dialogs";
import {closeDialogs} from "dialogs/utils";
import {turnLayerButtonOn} from "layers";
import {editUnits} from "modules/ui/editors";
import {aleaPRNG} from "scripts/aleaPRNG";
import {restoreDefaultEvents} from "scripts/events";
import {prompt} from "scripts/prompt";
@ -21,17 +22,17 @@ toolsContent.addEventListener("click", function (event) {
// click on open Editor buttons
if (button === "editHeightmapButton") editHeightmap();
else if (button === "editBiomesButton") editBiomes();
else if (button === "editStatesButton") editStates();
else if (button === "editStatesButton") openDialog("statesEditor");
else if (button === "editProvincesButton") editProvinces();
else if (button === "editDiplomacyButton") editDiplomacy();
else if (button === "editCulturesButton") editCultures();
else if (button === "editReligions") editReligions();
else if (button === "editCulturesButton") openDialog("culturesEditor");
else if (button === "editReligions") openDialog("religionsEditor");
else if (button === "editEmblemButton") openEmblemEditor();
else if (button === "editNamesBaseButton") editNamesbase();
else if (button === "editUnitsButton") editUnits();
else if (button === "editUnitsButton") openDialog("unitsEditor");
else if (button === "editNotesButton") editNotes();
else if (button === "editZonesButton") editZones();
else if (button === "overviewChartsButton") overviewCharts();
else if (button === "overviewChartsButton") openDialog("chartsOverview");
else if (button === "overviewBurgsButton") overviewBurgs();
else if (button === "overviewRiversButton") overviewRivers();
else if (button === "overviewMilitaryButton") overviewMilitary();
@ -867,8 +868,3 @@ function viewCellDetails() {
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
}
async function overviewCharts() {
const Overview = await import("../dynamic/overview/charts-overview.js");
Overview.open();
}

View file

@ -1,303 +0,0 @@
import * as d3 from "d3";
import {restoreDefaultEvents} from "scripts/events";
import {findCell} from "utils/graphUtils";
import {tip} from "scripts/tooltips";
import {prompt} from "scripts/prompt";
export function open() {
closeDialogs("#unitsEditor, .stable");
$("#unitsEditor").dialog();
if (fmg.modules.editUnits) return;
fmg.modules.editUnits = true;
$("#unitsEditor").dialog({
title: "Units Editor",
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
const drawBar = () => drawScaleBar(scale);
// add listeners
document.getElementById("distanceUnitInput").addEventListener("change", changeDistanceUnit);
document.getElementById("distanceScaleOutput").addEventListener("input", changeDistanceScale);
document.getElementById("distanceScaleInput").addEventListener("change", changeDistanceScale);
document.getElementById("heightUnit").addEventListener("change", changeHeightUnit);
document.getElementById("heightExponentInput").addEventListener("input", changeHeightExponent);
document.getElementById("heightExponentOutput").addEventListener("input", changeHeightExponent);
document.getElementById("temperatureScale").addEventListener("change", changeTemperatureScale);
document.getElementById("barSizeOutput").addEventListener("input", drawBar);
document.getElementById("barSizeInput").addEventListener("input", drawBar);
document.getElementById("barLabel").addEventListener("input", drawBar);
document.getElementById("barPosX").addEventListener("input", fitScaleBar);
document.getElementById("barPosY").addEventListener("input", fitScaleBar);
document.getElementById("barBackOpacity").addEventListener("input", changeScaleBarOpacity);
document.getElementById("barBackColor").addEventListener("input", changeScaleBarColor);
document.getElementById("populationRateOutput").addEventListener("input", changePopulationRate);
document.getElementById("populationRateInput").addEventListener("change", changePopulationRate);
document.getElementById("urbanizationOutput").addEventListener("input", changeUrbanizationRate);
document.getElementById("urbanizationInput").addEventListener("change", changeUrbanizationRate);
document.getElementById("urbanDensityOutput").addEventListener("input", changeUrbanDensity);
document.getElementById("urbanDensityInput").addEventListener("change", changeUrbanDensity);
document.getElementById("addLinearRuler").addEventListener("click", addRuler);
document.getElementById("addOpisometer").addEventListener("click", toggleOpisometerMode);
document.getElementById("addRouteOpisometer").addEventListener("click", toggleRouteOpisometerMode);
document.getElementById("addPlanimeter").addEventListener("click", togglePlanimeterMode);
document.getElementById("removeRulers").addEventListener("click", removeAllRulers);
document.getElementById("unitsRestore").addEventListener("click", restoreDefaultUnits);
function changeDistanceUnit() {
if (this.value === "custom_name") {
prompt("Provide a custom name for a distance unit", {default: ""}, custom => {
this.options.add(new Option(custom, custom, false, true));
lock("distanceUnit");
drawScaleBar(scale);
calculateFriendlyGridSize();
});
return;
}
drawScaleBar(scale);
calculateFriendlyGridSize();
}
function changeDistanceScale() {
drawScaleBar(scale);
calculateFriendlyGridSize();
}
function changeHeightUnit() {
if (this.value !== "custom_name") return;
prompt("Provide a custom name for a height unit", {default: ""}, custom => {
this.options.add(new Option(custom, custom, false, true));
lock("heightUnit");
});
}
function changeHeightExponent() {
calculateTemperatures();
if (layerIsOn("toggleTemp")) drawTemp();
}
function changeTemperatureScale() {
if (layerIsOn("toggleTemp")) drawTemp();
}
function changeScaleBarOpacity() {
scaleBar.select("rect").attr("opacity", this.value);
}
function changeScaleBarColor() {
scaleBar.select("rect").attr("fill", this.value);
}
function changePopulationRate() {
populationRate = +this.value;
}
function changeUrbanizationRate() {
urbanization = +this.value;
}
function changeUrbanDensity() {
urbanDensity = +this.value;
}
function restoreDefaultUnits() {
// distanceScale
distanceScale = 3;
document.getElementById("distanceScaleOutput").value = 3;
document.getElementById("distanceScaleInput").value = 3;
unlock("distanceScale");
// units
const US = navigator.language === "en-US";
const UK = navigator.language === "en-GB";
distanceUnitInput.value = US || UK ? "mi" : "km";
heightUnit.value = US || UK ? "ft" : "m";
temperatureScale.value = US ? "°F" : "°C";
areaUnit.value = "square";
localStorage.removeItem("distanceUnit");
localStorage.removeItem("heightUnit");
localStorage.removeItem("temperatureScale");
localStorage.removeItem("areaUnit");
calculateFriendlyGridSize();
// height exponent
heightExponentInput.value = heightExponentOutput.value = 1.8;
localStorage.removeItem("heightExponent");
calculateTemperatures();
// scale bar
barSizeOutput.value = barSizeInput.value = 2;
barLabel.value = "";
barBackOpacity.value = 0.2;
barBackColor.value = "#ffffff";
barPosX.value = barPosY.value = 99;
localStorage.removeItem("barSize");
localStorage.removeItem("barLabel");
localStorage.removeItem("barBackOpacity");
localStorage.removeItem("barBackColor");
localStorage.removeItem("barPosX");
localStorage.removeItem("barPosY");
drawScaleBar(scale);
// population
populationRate = populationRateOutput.value = populationRateInput.value = 1000;
urbanization = urbanizationOutput.value = urbanizationInput.value = 1;
urbanDensity = urbanDensityOutput.value = urbanDensityInput.value = 10;
localStorage.removeItem("populationRate");
localStorage.removeItem("urbanization");
localStorage.removeItem("urbanDensity");
}
function addRuler() {
if (!layerIsOn("toggleRulers")) toggleRulers();
const pt = document.getElementById("map").createSVGPoint();
(pt.x = graphWidth / 2), (pt.y = graphHeight / 4);
const p = pt.matrixTransform(viewbox.node().getScreenCTM().inverse());
const dx = graphWidth / 4 / scale;
const dy = (rulers.data.length * 40) % (graphHeight / 2);
const from = [(p.x - dx) | 0, (p.y + dy) | 0];
const to = [(p.x + dx) | 0, (p.y + dy) | 0];
rulers.create(Ruler, [from, to]).draw();
}
function toggleOpisometerMode() {
if (this.classList.contains("pressed")) {
restoreDefaultEvents();
clearMainTip();
this.classList.remove("pressed");
} else {
if (!layerIsOn("toggleRulers")) toggleRulers();
tip("Draw a curve to measure length. Hold Shift to disallow path optimization", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(
d3.drag().on("start", function () {
const point = d3.mouse(this);
const opisometer = rulers.create(Opisometer, [point]).draw();
d3.event.on("drag", function () {
const point = d3.mouse(this);
opisometer.addPoint(point);
});
d3.event.on("end", function () {
restoreDefaultEvents();
clearMainTip();
addOpisometer.classList.remove("pressed");
if (opisometer.points.length < 2) rulers.remove(opisometer.id);
if (!d3.event.sourceEvent.shiftKey) opisometer.optimize();
});
})
);
}
}
function toggleRouteOpisometerMode() {
if (this.classList.contains("pressed")) {
restoreDefaultEvents();
clearMainTip();
this.classList.remove("pressed");
} else {
if (!layerIsOn("toggleRulers")) toggleRulers();
tip("Draw a curve along routes to measure length. Hold Shift to measure away from roads.", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(
d3.drag().on("start", function () {
const cells = pack.cells;
const burgs = pack.burgs;
const point = d3.mouse(this);
const c = findCell(point[0], point[1]);
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
const b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1];
const routeOpisometer = rulers.create(RouteOpisometer, [[x, y]]).draw();
d3.event.on("drag", function () {
const point = d3.mouse(this);
const c = findCell(point[0], point[1]);
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
routeOpisometer.trackCell(c, true);
}
});
d3.event.on("end", function () {
restoreDefaultEvents();
clearMainTip();
addRouteOpisometer.classList.remove("pressed");
if (routeOpisometer.points.length < 2) {
rulers.remove(routeOpisometer.id);
}
});
} else {
restoreDefaultEvents();
clearMainTip();
addRouteOpisometer.classList.remove("pressed");
tip("Must start in a cell with a route in it", false, "error");
}
})
);
}
}
function togglePlanimeterMode() {
if (this.classList.contains("pressed")) {
restoreDefaultEvents();
clearMainTip();
this.classList.remove("pressed");
} else {
if (!layerIsOn("toggleRulers")) toggleRulers();
tip("Draw a curve to measure its area. Hold Shift to disallow path optimization", true);
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
this.classList.add("pressed");
viewbox.style("cursor", "crosshair").call(
d3.drag().on("start", function () {
const point = d3.mouse(this);
const planimeter = rulers.create(Planimeter, [point]).draw();
d3.event.on("drag", function () {
const point = d3.mouse(this);
planimeter.addPoint(point);
});
d3.event.on("end", function () {
restoreDefaultEvents();
clearMainTip();
addPlanimeter.classList.remove("pressed");
if (planimeter.points.length < 3) rulers.remove(planimeter.id);
else if (!d3.event.sourceEvent.shiftKey) planimeter.optimize();
});
})
);
}
}
function removeAllRulers() {
if (!rulers.data.length) return;
alertMessage.innerHTML = /* html */ ` Are you sure you want to remove all placed rulers?
<br />If you just want to hide rulers, toggle the Rulers layer off in Menu`;
$("#alert").dialog({
resizable: false,
title: "Remove all rulers",
buttons: {
Remove: function () {
$(this).dialog("close");
rulers.undraw();
rulers = new Rulers();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
}

View file

@ -7,6 +7,7 @@ import {tip, showMainTip, clearMainTip} from "scripts/tooltips";
import {rn} from "utils/numberUtils";
import {getNextId} from "utils/nodeUtils";
import {si} from "utils/unitUtils";
import {closeDialogs} from "dialogs/utils";
export function editZones() {
closeDialogs();