-
-
+ return /* html */ `
+
+
-
${c.length}
+
${cells.length}
-
${si(area) + unit}
+
${si(area) + " " + getAreaUnit()}
-
${si(population)}
+
${si(population)}
-
-
-
+
+
`;
});
@@ -121,14 +121,13 @@ function editZones() {
(d3.sum(pack.cells.pop) + d3.sum(pack.burgs.filter(b => !b.removed).map(b => b.population)) * urbanization) *
populationRate;
zonesFooterPopulation.dataset.population = totalPop;
- zonesFooterNumber.innerHTML = /* html */ `${filteredZones.length} of ${zones.length}`;
+ zonesFooterNumber.innerHTML = `${filteredZones.length} of ${pack.zones.length}`;
zonesFooterCells.innerHTML = pack.cells.i.length;
- zonesFooterArea.innerHTML = si(totalArea) + unit;
+ zonesFooterArea.innerHTML = si(totalArea) + " " + getAreaUnit();
zonesFooterPopulation.innerHTML = si(totalPop);
- // add listeners
- body.querySelectorAll("div.states").forEach(el => el.on("mouseenter", ev => zoneHighlightOn(ev)));
- body.querySelectorAll("div.states").forEach(el => el.on("mouseleave", ev => zoneHighlightOff(ev)));
+ body.querySelectorAll("div.states").forEach(el => el.on("mouseenter", zoneHighlightOn));
+ body.querySelectorAll("div.states").forEach(el => el.on("mouseleave", zoneHighlightOff));
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
@@ -138,25 +137,17 @@ function editZones() {
}
function zoneHighlightOn(event) {
- const zone = event.target.dataset.id;
- zones.select("#" + zone).style("outline", "1px solid red");
+ const zoneId = event.target.dataset.id;
+ zones.select("#zone" + zoneId).style("outline", "1px solid red");
}
function zoneHighlightOff(event) {
- const zone = event.target.dataset.id;
- zones.select("#" + zone).style("outline", null);
+ const zoneId = event.target.dataset.id;
+ zones.select("#zone" + zoneId).style("outline", null);
}
function filterZonesByType() {
- const typeToFilterBy = this.value;
- const zones = Array.from(document.querySelectorAll("#zones > g"));
-
- for (const zone of zones) {
- const type = zone.dataset.type;
- const visible = typeToFilterBy === "all" || type === typeToFilterBy;
- zone.style.display = visible ? "block" : "none";
- }
-
+ drawZones();
zonesEditorAddLines();
}
@@ -167,23 +158,24 @@ function editZones() {
axis: "y",
update: movezone
});
- function movezone(ev, ui) {
- const zone = $("#" + ui.item.attr("data-id"));
- const prev = $("#" + ui.item.prev().attr("data-id"));
- if (prev) {
- zone.insertAfter(prev);
- return;
- }
- const next = $("#" + ui.item.next().attr("data-id"));
- if (next) zone.insertBefore(next);
+
+ function movezone(_ev, ui) {
+ const zone = pack.zones.find(z => z.i === +ui.item[0].dataset.id);
+ const oldIndex = pack.zones.indexOf(zone);
+ const newIndex = ui.item.index();
+ if (oldIndex === newIndex) return;
+
+ pack.zones.splice(oldIndex, 1);
+ pack.zones.splice(newIndex, 0, zone);
+ drawZones();
}
function enterZonesManualAssignent() {
if (!layerIsOn("toggleZones")) toggleZones();
customization = 10;
+
document.querySelectorAll("#zonesBottom > *").forEach(el => (el.style.display = "none"));
byId("zonesManuallyButtons").style.display = "inline-block";
-
zonesEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
zonesFooter.style.display = "none";
body.querySelectorAll("div > input, select, svg").forEach(e => (e.style.pointerEvents = "none"));
@@ -197,21 +189,32 @@ function editZones() {
.on("touchmove mousemove", moveZoneBrush);
body.querySelector("div").classList.add("selected");
- zones.selectAll("g").each(function () {
- this.setAttribute("data-init", this.getAttribute("data-cells"));
- });
- }
- function selectZone(el) {
- body.querySelector("div.selected").classList.remove("selected");
- el.classList.add("selected");
+ // draw zones as individual cells
+ zones.selectAll("*").remove();
+
+ const filterBy = byId("zonesFilterType").value;
+ const isFiltered = filterBy && filterBy !== "all";
+ const visibleZones = pack.zones.filter(zone => !zone.hidden && (!isFiltered || zone.type === filterBy));
+ const data = visibleZones.map(({i, cells, color}) => cells.map(cell => ({cell, zoneId: i, fill: color}))).flat();
+ zones
+ .selectAll("polygon")
+ .data(data, d => `${d.zoneId}-${d.cell}`)
+ .enter()
+ .append("polygon")
+ .attr("points", d => getPackPolygon(d.cell))
+ .attr("fill", d => d.fill)
+ .attr("data-zone", d => d.zoneId)
+ .attr("data-cell", d => d.cell);
}
function selectZoneOnMapClick() {
- if (d3.event.target.parentElement.parentElement.id !== "zones") return;
- const zone = d3.event.target.parentElement.id;
- const el = body.querySelector("div[data-id='" + zone + "']");
- selectZone(el);
+ if (d3.event.target.parentElement.id !== "zones") return;
+ const zoneId = d3.event.target.dataset.zone;
+ const el = body.querySelector("div[data-id='" + zoneId + "']");
+
+ body.querySelector("div.selected").classList.remove("selected");
+ el.classList.add("selected");
}
function dragZoneBrush() {
@@ -219,43 +222,41 @@ function editZones() {
const eraseMode = byId("zonesRemove").classList.contains("pressed");
const landOnly = byId("zonesBrushLandOnly").checked;
- const selected = body.querySelector("div.selected");
- const zone = zones.select("#" + selected.dataset.id);
- const base = zone.attr("id") + "_"; // id generic part
-
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const [x, y] = d3.mouse(this);
moveCircle(x, y, radius);
- let selection = radius > 5 ? findAll(x, y, radius) : [findCell(x, y, radius)];
+ let selection = radius > 5 ? findAll(x, y, radius) : [findCell(x, y)];
if (landOnly) selection = selection.filter(i => pack.cells.h[i] >= 20);
- if (!selection) return;
+ if (!selection.length) return;
- const dataCells = zone.attr("data-cells");
- let cells = dataCells ? dataCells.split(",").map(i => +i) : [];
+ const zoneId = +body.querySelector("div.selected")?.dataset.id;
+ const zone = pack.zones.find(z => z.i === zoneId);
+ if (!zone) return;
if (eraseMode) {
- // remove
- selection.forEach(i => {
- const index = cells.indexOf(i);
- if (index === -1) return;
- zone.select("polygon#" + base + i).remove();
- cells.splice(index, 1);
- });
+ const data = zones
+ .selectAll("polygon")
+ .data()
+ .filter(d => !(d.zoneId === zoneId && selection.includes(d.cell)));
+ zones
+ .selectAll("polygon")
+ .data(data, d => `${d.zoneId}-${d.cell}`)
+ .exit()
+ .remove();
} else {
- // add
- selection.forEach(i => {
- if (cells.includes(i)) return;
- cells.push(i);
- zone
- .append("polygon")
- .attr("points", getPackPolygon(i))
- .attr("id", base + i);
- });
+ const data = selection.map(cell => ({cell, zoneId, fill: zone.color}));
+ zones
+ .selectAll("polygon")
+ .data(data, d => `${d.zoneId}-${d.cell}`)
+ .enter()
+ .append("polygon")
+ .attr("points", d => getPackPolygon(d.cell))
+ .attr("fill", d => d.fill)
+ .attr("data-zone", d => d.zoneId)
+ .attr("data-cell", d => d.cell);
}
-
- zone.attr("data-cells", cells);
});
}
@@ -263,39 +264,29 @@ function editZones() {
showMainTip();
const point = d3.mouse(this);
const radius = +zonesBrush.value;
- moveCircle(point[0], point[1], radius);
+ moveCircle(...point, radius);
}
function applyZonesManualAssignent() {
- zones.selectAll("g").each(function () {
- if (this.dataset.cells) return;
- // all zone cells are removed
- unfog("focusZone" + this.id);
- this.style.display = "block";
- });
+ const data = zones.selectAll("polygon").data();
+ const zoneCells = data.reduce((acc, d) => {
+ if (!acc[d.zoneId]) acc[d.zoneId] = [];
+ acc[d.zoneId].push(d.cell);
+ return acc;
+ }, {});
+ const filterBy = byId("zonesFilterType").value;
+ const isFiltered = filterBy && filterBy !== "all";
+ const visibleZones = pack.zones.filter(zone => !zone.hidden && (!isFiltered || zone.type === filterBy));
+ visibleZones.forEach(zone => (zone.cells = zoneCells[zone.i] || []));
+
+ drawZones();
zonesEditorAddLines();
exitZonesManualAssignment();
}
- // restore initial zone cells
function cancelZonesManualAssignent() {
- zones.selectAll("g").each(function () {
- const zone = d3.select(this);
- const dataCells = zone.attr("data-init");
- const cells = dataCells ? dataCells.split(",").map(i => +i) : [];
- zone.attr("data-cells", cells);
- zone.selectAll("*").remove();
- const base = zone.attr("id") + "_"; // id generic part
- zone
- .selectAll("*")
- .data(cells)
- .enter()
- .append("polygon")
- .attr("points", d => getPackPolygon(d))
- .attr("id", d => base + d);
- });
-
+ drawZones();
exitZonesManualAssignment();
}
@@ -313,60 +304,47 @@ function editZones() {
restoreDefaultEvents();
clearMainTip();
- zones.selectAll("g").each(function () {
- this.removeAttribute("data-init");
- });
+
const selected = body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
}
- function changeFill(el) {
- const fill = el.getAttribute("fill");
+ function changeFill(fill, zone) {
const callback = newFill => {
- el.fill = newFill;
- byId(el.parentNode.dataset.id).setAttribute("fill", newFill);
+ zone.color = newFill;
+ drawZones();
+ zonesEditorAddLines();
};
openPicker(fill, callback);
}
- function toggleVisibility(el) {
- const zone = zones.select("#" + el.parentNode.dataset.id);
- const inactive = zone.style("display") === "none";
- inactive ? zone.style("display", "block") : zone.style("display", "none");
- el.classList.toggle("inactive");
+ function toggleVisibility(zone) {
+ const isHidden = Boolean(zone.hidden);
+ if (isHidden) delete zone.hidden;
+ else zone.hidden = true;
+
+ drawZones();
+ zonesEditorAddLines();
}
- function toggleFog(z, cl) {
- const dataCells = zones.select("#" + z).attr("data-cells");
- if (!dataCells) return;
-
- const path =
- "M" +
- dataCells
- .split(",")
- .map(c => getPackPolygon(+c))
- .join("M") +
- "Z",
- id = "focusZone" + z;
- cl.contains("inactive") ? fog(id, path) : unfog(id);
+ function toggleFog(zone, cl) {
+ const inactive = cl.contains("inactive");
cl.toggle("inactive");
+
+ if (inactive) {
+ const path = zones.select("#zone" + zone.i).attr("d");
+ fog("focusZone" + zone.i, path);
+ } else {
+ unfog("focusZone" + zone.i);
+ }
}
function toggleLegend() {
- if (legend.selectAll("*").size()) {
- clearLegend();
- return;
- } // hide legend
- const data = [];
-
- zones.selectAll("g").each(function () {
- const id = this.dataset.id;
- const description = this.dataset.description;
- const fill = this.getAttribute("fill");
- data.push([id, fill, description]);
- });
-
+ const filterBy = byId("zonesFilterType").value;
+ const isFiltered = filterBy && filterBy !== "all";
+ const visibleZones = pack.zones.filter(zone => !zone.hidden && (!isFiltered || zone.type === filterBy));
+ const data = visibleZones.map(({i, name, color}) => ["zone" + i, color, name]);
drawLegend("Zones", data);
}
@@ -380,8 +358,7 @@ function editZones() {
body.querySelectorAll(":scope > div").forEach(function (el) {
el.querySelector(".stateCells").innerHTML = rn((+el.dataset.cells / totalCells) * 100, 2) + "%";
el.querySelector(".biomeArea").innerHTML = rn((+el.dataset.area / totalArea) * 100, 2) + "%";
- el.querySelector(".culturePopulation").innerHTML =
- rn((+el.dataset.population / totalPopulation) * 100, 2) + "%";
+ el.querySelector(".zonePopulation").innerHTML = rn((+el.dataset.population / totalPopulation) * 100, 2) + "%";
});
} else {
body.dataset.type = "absolute";
@@ -390,28 +367,23 @@ function editZones() {
}
function addZonesLayer() {
- const id = getNextId("zone");
- const description = "Unknown zone";
+ const zoneId = pack.zones.length ? Math.max(...pack.zones.map(z => z.i)) + 1 : 0;
+ const name = "Unknown zone";
const type = "Unknown";
- const fill = "url(#hatch" + (id.slice(4) % 42) + ")";
- zones
- .append("g")
- .attr("id", id)
- .attr("data-description", description)
- .attr("data-type", type)
- .attr("data-cells", "")
- .attr("fill", fill);
+ const color = "url(#hatch" + (zoneId % 42) + ")";
+ pack.zones.push({i: zoneId, name, type, color, cells: []});
zonesEditorAddLines();
+ drawZones();
}
function downloadZonesData() {
const unit = areaUnit.value === "square" ? distanceUnitInput.value + "2" : areaUnit.value;
- let data = "Id,Fill,Description,Type,Cells,Area " + unit + ",Population\n"; // headers
+ let data = "Id,Color,Description,Type,Cells,Area " + unit + ",Population\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
data += el.dataset.id + ",";
- data += el.dataset.fill + ",";
+ data += el.dataset.color + ",";
data += el.dataset.description + ",";
data += el.dataset.type + ",";
data += el.dataset.cells + ",";
@@ -423,27 +395,24 @@ function editZones() {
downloadFile(data, name);
}
- function toggleEraseMode() {
- this.classList.toggle("pressed");
+ function changeDescription(zone, value) {
+ zone.name = value;
+ zones.select("#zone" + zone.i).attr("data-description", value);
+ }
+
+ function changeType(zone, value) {
+ zone.type = value;
+ zones.select("#zone" + zone.i).attr("data-type", value);
}
function changePopulation(zone) {
- const dataCells = zones.select("#" + zone).attr("data-cells");
- const cells = dataCells
- ? dataCells
- .split(",")
- .map(i => +i)
- .filter(i => pack.cells.h[i] >= 20)
- : [];
- if (!cells.length) {
- tip("Zone does not have any land cells, cannot change population", false, "error");
- return;
- }
- const burgs = pack.burgs.filter(b => !b.removed && cells.includes(b.cell));
+ const landCells = zone.cells.filter(i => pack.cells.h[i] >= 20);
+ if (!landCells.length) return tip("Zone does not have any land cells, cannot change population", false, "error");
- const rural = rn(d3.sum(cells.map(i => pack.cells.pop[i])) * populationRate);
+ const burgs = pack.burgs.filter(b => !b.removed && landCells.includes(b.cell));
+ const rural = rn(d3.sum(landCells.map(i => pack.cells.pop[i])) * populationRate);
const urban = rn(
- d3.sum(cells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization
+ d3.sum(landCells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization
);
const total = rural + urban;
const l = n => Number(n).toLocaleString();
@@ -485,12 +454,12 @@ function editZones() {
function applyPopulationChange() {
const ruralChange = ruralPop.value / rural;
if (isFinite(ruralChange) && ruralChange !== 1) {
- cells.forEach(i => (pack.cells.pop[i] *= ruralChange));
+ landCells.forEach(i => (pack.cells.pop[i] *= ruralChange));
}
if (!isFinite(ruralChange) && +ruralPop.value > 0) {
const points = ruralPop.value / populationRate;
- const pop = rn(points / cells.length);
- cells.forEach(i => (pack.cells.pop[i] = pop));
+ const pop = rn(points / landCells.length);
+ landCells.forEach(i => (pack.cells.pop[i] = pop));
}
const urbanChange = urbanPop.value / urban;
@@ -508,8 +477,16 @@ function editZones() {
}
function zoneRemove(zone) {
- zones.select("#" + zone).remove();
- unfog("focusZone" + zone);
- zonesEditorAddLines();
+ confirmationDialog({
+ title: "Remove zone",
+ message: "Are you sure you want to remove the zone?
This action cannot be reverted",
+ confirm: "Remove",
+ onConfirm: () => {
+ pack.zones = pack.zones.filter(z => z.i !== zone.i);
+ zones.select("#zone" + zone.i).remove();
+ unfog("focusZone" + zone.i);
+ zonesEditorAddLines();
+ }
+ });
}
}
diff --git a/modules/zones-generator.js b/modules/zones-generator.js
new file mode 100644
index 00000000..e0923615
--- /dev/null
+++ b/modules/zones-generator.js
@@ -0,0 +1,447 @@
+"use strict";
+
+window.Zones = (function () {
+ const config = {
+ invasion: {quantity: 2, generate: addInvasion}, // invasion of enemy lands
+ rebels: {quantity: 1.5, generate: addRebels}, // rebels along a state border
+ proselytism: {quantity: 1.6, generate: addProselytism}, // proselitism of organized religion
+ crusade: {quantity: 1.6, generate: addCrusade}, // crusade on heresy lands
+ disease: {quantity: 1.4, generate: addDisease}, // disease starting in a random city
+ disaster: {quantity: 1, generate: addDisaster}, // disaster starting in a random city
+ eruption: {quantity: 1, generate: addEruption}, // eruption aroung volcano
+ avalanche: {quantity: 0.8, generate: addAvalanche}, // avalanche impacting highland road
+ fault: {quantity: 1, generate: addFault}, // fault line in elevated areas
+ flood: {quantity: 1, generate: addFlood}, // flood on river banks
+ tsunami: {quantity: 1, generate: addTsunami} // tsunami starting near coast
+ };
+
+ const generate = function (globalModifier = 1) {
+ TIME && console.time("generateZones");
+
+ const usedCells = new Uint8Array(pack.cells.i.length);
+ pack.zones = [];
+
+ Object.values(config).forEach(type => {
+ const expectedNumber = type.quantity * globalModifier;
+ let number = gauss(expectedNumber, expectedNumber / 2, 0, 100);
+ while (number--) type.generate(usedCells);
+ });
+
+ TIME && console.timeEnd("generateZones");
+ };
+
+ function addInvasion(usedCells) {
+ const {cells, states} = pack;
+
+ const ongoingConflicts = states
+ .filter(s => s.i && !s.removed && s.campaigns)
+ .map(s => s.campaigns)
+ .flat()
+ .filter(c => !c.end);
+ if (!ongoingConflicts.length) return;
+ const {defender, attacker} = ra(ongoingConflicts);
+
+ const borderCells = cells.i.filter(cellId => {
+ if (usedCells[cellId]) return false;
+ if (cells.state[cellId] !== defender) return false;
+ return cells.c[cellId].some(c => cells.state[c] === attacker);
+ });
+
+ const startCell = ra(borderCells);
+ if (startCell === undefined) return;
+
+ const invationCells = [];
+ const queue = [startCell];
+ const maxCells = rand(5, 30);
+
+ while (queue.length) {
+ const cellId = P(0.4) ? queue.shift() : queue.pop();
+ invationCells.push(cellId);
+ if (invationCells.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId]) return;
+ if (cells.state[neibCellId] !== defender) return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const subtype = rw({
+ Invasion: 5,
+ Occupation: 4,
+ Conquest: 3,
+ Incursion: 2,
+ Intervention: 2,
+ Subjugation: 1,
+ Foray: 1,
+ Skirmishes: 1,
+ Pillaging: 1,
+ Raid: 1
+ });
+ const name = getAdjective(states[attacker].name) + " " + subtype;
+
+ pack.zones.push({i: pack.zones.length, name, type: "Invasion", cells: invationCells, color: "url(#hatch1)"});
+ }
+
+ function addRebels(usedCells) {
+ const {cells, states} = pack;
+
+ const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(Boolean)));
+ if (!state) return;
+
+ const neibStateId = ra(state.neighbors.filter(n => n && !states[n].removed));
+ if (!neibStateId) return;
+
+ const cellsArray = [];
+ const queue = [];
+ const borderCellId = cells.i.find(
+ i => cells.state[i] === state.i && cells.c[i].some(c => cells.state[c] === neibStateId)
+ );
+ if (borderCellId) queue.push(borderCellId);
+ const maxCells = rand(10, 30);
+
+ while (queue.length) {
+ const cellId = queue.shift();
+ cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId]) return;
+ if (cells.state[neibCellId] !== state.i) return;
+ usedCells[neibCellId] = 1;
+ if (neibCellId % 4 !== 0 && !cells.c[neibCellId].some(c => cells.state[c] === neibStateId)) return;
+ queue.push(neibCellId);
+ });
+ }
+
+ const rebels = rw({
+ Rebels: 5,
+ Insurrection: 2,
+ Mutineers: 1,
+ Insurgents: 1,
+ Rioters: 1,
+ Separatists: 1,
+ Secessionists: 1,
+ Rebellion: 1,
+ Conspiracy: 1
+ });
+
+ const name = getAdjective(states[neibStateId].name) + " " + rebels;
+ pack.zones.push({i: pack.zones.length, name, type: "Rebels", cells: cellsArray, color: "url(#hatch3)"});
+ }
+
+ function addProselytism(usedCells) {
+ const {cells, religions} = pack;
+
+ const organizedReligions = religions.filter(r => r.i && !r.removed && r.type === "Organized");
+ const religion = ra(organizedReligions);
+ if (!religion) return;
+
+ const targetBorderCells = cells.i.filter(
+ i =>
+ cells.h[i] < 20 &&
+ cells.pop[i] &&
+ cells.religion[i] !== religion.i &&
+ cells.c[i].some(c => cells.religion[c] === religion.i)
+ );
+ const startCell = ra(targetBorderCells);
+ if (!startCell) return;
+
+ const targetReligionId = cells.religion[startCell];
+ const proselytismCells = [];
+ const queue = [startCell];
+ const maxCells = rand(10, 30);
+
+ while (queue.length) {
+ const cellId = queue.shift();
+ proselytismCells.push(cellId);
+ if (proselytismCells.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId]) return;
+ if (cells.religion[neibCellId] !== targetReligionId) return;
+ if (cells.h[neibCellId] < 20 || !cells.pop[i]) return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const name = `${getAdjective(religion.name.split(" ")[0])} Proselytism`;
+ pack.zones.push({i: pack.zones.length, name, type: "Proselytism", cells: proselytismCells, color: "url(#hatch6)"});
+ }
+
+ function addCrusade(usedCells) {
+ const {cells, religions} = pack;
+
+ const heresies = religions.filter(r => !r.removed && r.type === "Heresy");
+ if (!heresies.length) return;
+
+ const heresy = ra(heresies);
+ const crusadeCells = cells.i.filter(i => !usedCells[i] && cells.religion[i] === heresy.i);
+ if (!crusadeCells.length) return;
+ crusadeCells.forEach(i => (usedCells[i] = 1));
+
+ const name = getAdjective(heresy.name.split(" ")[0]) + " Crusade";
+ pack.zones.push({
+ i: pack.zones.length,
+ name,
+ type: "Crusade",
+ cells: Array.from(crusadeCells),
+ color: "url(#hatch6)"
+ });
+ }
+
+ function addDisease(usedCells) {
+ const {cells, burgs} = pack;
+
+ const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed)); // random burg
+ if (!burg) return;
+
+ const cellsArray = [];
+ const cost = [];
+ const maxCells = rand(20, 40);
+
+ const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
+ queue.queue({e: burg.cell, p: 0});
+
+ while (queue.length) {
+ const next = queue.dequeue();
+ if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
+ usedCells[next.e] = 1;
+
+ cells.c[next.e].forEach(nextCellId => {
+ const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100;
+ const p = next.p + c;
+ if (p > maxCells) return;
+
+ if (!cost[nextCellId] || p < cost[nextCellId]) {
+ cost[nextCellId] = p;
+ queue.queue({e: nextCellId, p});
+ }
+ });
+ }
+
+ // prettier-ignore
+ const name = `${(() => {
+ const model = rw({color: 2, animal: 1, adjective: 1});
+ if (model === "color") return ra(["Amber", "Azure", "Black", "Blue", "Brown", "Crimson", "Emerald", "Golden", "Green", "Grey", "Orange", "Pink", "Purple", "Red", "Ruby", "Scarlet", "Silver", "Violet", "White", "Yellow"]);
+ if (model === "animal") return ra(["Ape", "Bear", "Bird", "Boar", "Cat", "Cow", "Dog", "Fox", "Horse", "Lion", "Pig", "Rat", "Raven", "Sheep", "Spider", "Tiger", "Viper", "Wolf", "Worm", "Wyrm"]);
+ if (model === "adjective") return ra(["Blind", "Bloody", "Brutal", "Burning", "Deadly", "Fatal", "Furious", "Great", "Grim", "Horrible", "Invisible", "Lethal", "Loud", "Mortal", "Savage", "Severe", "Silent", "Unknown", "Venomous", "Vicious"]);
+ })()} ${rw({Fever: 5, Plague: 3, Cough: 3, Flu: 2, Pox: 2, Cholera: 2, Typhoid: 2, Leprosy: 1, Smallpox: 1, Pestilence: 1, Consumption: 1, Malaria: 1, Dropsy: 1})}`;
+
+ pack.zones.push({i: pack.zones.length, name, type: "Disease", cells: cellsArray, color: "url(#hatch12)"});
+ }
+
+ function addDisaster(usedCells) {
+ const {cells, burgs} = pack;
+
+ const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed));
+ if (!burg) return;
+ usedCells[burg.cell] = 1;
+
+ const cellsArray = [];
+ const cost = [];
+ const maxCells = rand(5, 25);
+
+ const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
+ queue.queue({e: burg.cell, p: 0});
+
+ while (queue.length) {
+ const next = queue.dequeue();
+ if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
+ usedCells[next.e] = 1;
+
+ cells.c[next.e].forEach(function (e) {
+ const c = rand(1, 10);
+ const p = next.p + c;
+ if (p > maxCells) return;
+
+ if (!cost[e] || p < cost[e]) {
+ cost[e] = p;
+ queue.queue({e, p});
+ }
+ });
+ }
+
+ const type = rw({
+ Famine: 5,
+ Drought: 3,
+ Earthquake: 3,
+ Dearth: 1,
+ Tornadoes: 1,
+ Wildfires: 1,
+ Storms: 1,
+ Blight: 1
+ });
+ const name = getAdjective(burg.name) + " " + type;
+ pack.zones.push({i: pack.zones.length, name, type: "Disaster", cells: cellsArray, color: "url(#hatch5)"});
+ }
+
+ function addEruption(usedCells) {
+ const {cells, markers} = pack;
+
+ const volcanoe = markers.find(m => m.type === "volcanoes" && !usedCells[m.cell]);
+ if (!volcanoe) return;
+ usedCells[volcanoe.cell] = 1;
+
+ const note = notes.find(n => n.id === "marker" + volcanoe.i);
+ if (note) note.legend = note.legend.replace("Active volcano", "Erupting volcano");
+ const name = note ? note.name.replace(" Volcano", "") + " Eruption" : "Volcano Eruption";
+
+ const cellsArray = [];
+ const queue = [volcanoe.cell];
+ const maxCells = rand(10, 30);
+
+ while (queue.length) {
+ const cellId = P(0.5) ? queue.shift() : queue.pop();
+ cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId] || cells.h[neibCellId] < 20) return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ pack.zones.push({i: pack.zones.length, name, type: "Eruption", cells: cellsArray, color: "url(#hatch7)"});
+ }
+
+ function addAvalanche(usedCells) {
+ const {cells} = pack;
+
+ const routeCells = cells.i.filter(i => !usedCells[i] && Routes.isConnected(i) && cells.h[i] >= 70);
+ if (!routeCells.length) return;
+
+ const startCell = ra(routeCells);
+ usedCells[startCell] = 1;
+
+ const cellsArray = [];
+ const queue = [startCell];
+ const maxCells = rand(3, 15);
+
+ while (queue.length) {
+ const cellId = P(0.3) ? queue.shift() : queue.pop();
+ cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId] || cells.h[neibCellId] < 65) return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Avalanche";
+ pack.zones.push({i: pack.zones.length, name, type: "Avalanche", cells: cellsArray, color: "url(#hatch5)"});
+ }
+
+ function addFault(usedCells) {
+ const cells = pack.cells;
+
+ const elevatedCells = cells.i.filter(i => !usedCells[i] && cells.h[i] > 50 && cells.h[i] < 70);
+ if (!elevatedCells.length) return;
+
+ const startCell = ra(elevatedCells);
+ usedCells[startCell] = 1;
+
+ const cellsArray = [];
+ const queue = [startCell];
+ const maxCells = rand(3, 15);
+
+ while (queue.length) {
+ const cellId = queue.pop();
+ if (cells.h[cellId] >= 20) cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId] || cells.r[neibCellId]) return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Fault";
+ pack.zones.push({i: pack.zones.length, name, type: "Fault", cells: cellsArray, color: "url(#hatch2)"});
+ }
+
+ function addFlood(usedCells) {
+ const cells = pack.cells;
+
+ const fl = cells.fl.filter(Boolean);
+ const meanFlux = d3.mean(fl);
+ const maxFlux = d3.max(fl);
+ const fluxThreshold = (maxFlux - meanFlux) / 2 + meanFlux;
+
+ const bigRiverCells = cells.i.filter(
+ i => !usedCells[i] && cells.h[i] < 50 && cells.r[i] && cells.fl[i] > fluxThreshold && cells.burg[i]
+ );
+ if (!bigRiverCells.length) return;
+
+ const startCell = ra(bigRiverCells);
+ usedCells[startCell] = 1;
+
+ const riverId = cells.r[startCell];
+ const cellsArray = [];
+ const queue = [startCell];
+ const maxCells = rand(5, 30);
+
+ while (queue.length) {
+ const cellId = queue.pop();
+ cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (
+ usedCells[neibCellId] ||
+ cells.h[neibCellId] < 20 ||
+ cells.r[neibCellId] !== riverId ||
+ cells.h[neibCellId] > 50 ||
+ cells.fl[neibCellId] < meanFlux
+ )
+ return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const name = getAdjective(pack.burgs[cells.burg[startCell]].name) + " Flood";
+ pack.zones.push({i: pack.zones.length, name, type: "Flood", cells: cellsArray, color: "url(#hatch13)"});
+ }
+
+ function addTsunami(usedCells) {
+ const {cells, features} = pack;
+
+ const coastalCells = cells.i.filter(
+ i => !usedCells[i] && cells.t[i] === -1 && features[cells.f[i]].type !== "lake"
+ );
+ if (!coastalCells.length) return;
+
+ const startCell = ra(coastalCells);
+ usedCells[startCell] = 1;
+
+ const cellsArray = [];
+ const queue = [startCell];
+ const maxCells = rand(10, 30);
+
+ while (queue.length) {
+ const cellId = queue.shift();
+ if (cells.t[cellId] === 1) cellsArray.push(cellId);
+ if (cellsArray.length >= maxCells) break;
+
+ cells.c[cellId].forEach(neibCellId => {
+ if (usedCells[neibCellId]) return;
+ if (cells.t[neibCellId] > 2) return;
+ if (pack.features[cells.f[neibCellId]].type === "lake") return;
+ usedCells[neibCellId] = 1;
+ queue.push(neibCellId);
+ });
+ }
+
+ const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Tsunami";
+ pack.zones.push({i: pack.zones.length, name, type: "Tsunami", cells: cellsArray, color: "url(#hatch13)"});
+ }
+
+ return {generate};
+})();
diff --git a/styles/ancient.json b/styles/ancient.json
index 8bc05ec2..d57aa524 100644
--- a/styles/ancient.json
+++ b/styles/ancient.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 12,
"font-size": 12,
"font-family": "Great Vibes"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 5,
"font-size": 5,
"font-family": "Great Vibes"
@@ -384,6 +386,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 22,
"font-size": 22,
"font-family": "Great Vibes",
@@ -395,6 +398,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Times New Roman",
diff --git a/styles/atlas.json b/styles/atlas.json
index d7990935..ef7d7f8a 100644
--- a/styles/atlas.json
+++ b/styles/atlas.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 5,
"font-size": 5,
"font-family": "Amarante"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "Amarante"
@@ -384,6 +386,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 21,
"font-size": 21,
"font-family": "Amarante",
@@ -395,6 +398,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Amarante",
diff --git a/styles/clean.json b/styles/clean.json
index 82681e28..c5aad094 100644
--- a/styles/clean.json
+++ b/styles/clean.json
@@ -319,22 +319,22 @@
"mask": "url(#land)"
},
"#legend": {
- "data-size": 12.74,
- "font-size": 12.74,
+ "data-size": 12,
+ "font-size": 12,
"font-family": "Arial",
"stroke": "#909090",
- "stroke-width": 1.13,
+ "stroke-width": 1,
"stroke-dasharray": 0,
"stroke-linecap": "round",
- "data-x": 98.39,
- "data-y": 12.67,
- "data-columns": null
+ "data-x": 99,
+ "data-y": 93,
+ "data-columns": 8
},
- "#legendBox": {},
"#burgLabels > #cities": {
"opacity": 1,
"fill": "#414141",
"text-shadow": "white 0 0 4px",
+ "letter-spacing": 0,
"data-size": 7,
"font-size": 7,
"font-family": "Arial"
@@ -359,6 +359,8 @@
"#burgLabels > #towns": {
"opacity": 1,
"fill": "#414141",
+ "text-shadow": "none",
+ "letter-spacing": 0,
"data-size": 3,
"font-size": 3,
"font-family": "Arial"
@@ -386,6 +388,7 @@
"stroke": "#303030",
"stroke-width": 0,
"text-shadow": "white 0 0 2px",
+ "letter-spacing": 0,
"data-size": 10,
"font-size": 10,
"font-family": "Arial",
@@ -397,6 +400,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0 0 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Arial",
diff --git a/styles/cyberpunk.json b/styles/cyberpunk.json
index 2821eb7f..93f22284 100644
--- a/styles/cyberpunk.json
+++ b/styles/cyberpunk.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#ffffff",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 8,
"font-size": 8,
"font-family": "Orbitron"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#ffffff",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 3,
"font-size": 3,
"font-family": "Orbitron"
@@ -384,6 +386,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Orbitron",
@@ -395,6 +398,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Almendra SC",
diff --git a/styles/darkSeas.json b/styles/darkSeas.json
index 9bed1ef2..2bc90fa6 100644
--- a/styles/darkSeas.json
+++ b/styles/darkSeas.json
@@ -321,6 +321,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 7,
"font-size": 7,
"font-family": "Lugrasimo"
@@ -345,6 +346,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 5,
"font-size": 5,
"font-family": "Lugrasimo"
@@ -371,6 +373,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 21,
"font-size": 21,
"font-family": "Eagle Lake",
@@ -382,6 +385,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Eagle Lake",
diff --git a/styles/default.json b/styles/default.json
index 623e37f3..9168debc 100644
--- a/styles/default.json
+++ b/styles/default.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 7,
"font-size": 7,
"font-family": "Almendra SC"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "Almendra SC"
@@ -384,6 +386,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 22,
"font-size": 22,
"font-family": "Almendra SC",
@@ -395,6 +398,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Almendra SC",
diff --git a/styles/gloom.json b/styles/gloom.json
index 858c807c..19318882 100644
--- a/styles/gloom.json
+++ b/styles/gloom.json
@@ -335,6 +335,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0 0 2px",
+ "letter-spacing": 0,
"data-size": 8,
"font-size": 8,
"font-family": "Underdog"
@@ -359,6 +360,8 @@
"#burgLabels > #towns": {
"opacity": 1,
"fill": "#3e3e4b",
+ "text-shadow": "none",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "Underdog"
@@ -386,6 +389,7 @@
"stroke": "#b5b5b5",
"stroke-width": 0,
"text-shadow": "white 0 0 2px",
+ "letter-spacing": 0,
"data-size": 20,
"font-size": 20,
"font-family": "Underdog",
@@ -397,6 +401,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0 0 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Underdog",
diff --git a/styles/light.json b/styles/light.json
index cf846e1b..de539872 100644
--- a/styles/light.json
+++ b/styles/light.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#3a3a3a",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 8,
"font-size": 8,
"font-family": "IM Fell English"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "IM Fell English"
@@ -384,6 +386,7 @@
"stroke": "#000000",
"stroke-width": 0.3,
"text-shadow": "white 0px 0px 6px",
+ "letter-spacing": 0,
"data-size": 14,
"font-size": 14,
"font-family": "IM Fell English",
@@ -395,6 +398,7 @@
"stroke": "#701b05",
"stroke-width": 0.1,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 6,
"font-size": 6,
"font-family": "IM Fell English",
diff --git a/styles/monochrome.json b/styles/monochrome.json
index 20d3e588..1ee17c43 100644
--- a/styles/monochrome.json
+++ b/styles/monochrome.json
@@ -328,6 +328,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 7,
"font-size": 7,
"font-family": "Courier New"
@@ -353,6 +354,7 @@
"opacity": 1,
"fill": "#000000",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "Courier New"
@@ -380,6 +382,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Courier New",
@@ -390,7 +393,8 @@
"fill": "#3e3e4b",
"stroke": "#3a3a3a",
"stroke-width": 0,
- "text-shadow": "white 0 0 4px",
+ "text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Courier New",
diff --git a/styles/night.json b/styles/night.json
index 90b40e75..67a5e799 100644
--- a/styles/night.json
+++ b/styles/night.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#dbdbe1",
"text-shadow": "black 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 8,
"font-size": 8,
"font-family": "Courier New"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#ffffff",
"text-shadow": "black 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4.28,
"font-size": 4.28,
"font-family": "Courier New"
@@ -384,6 +386,7 @@
"stroke": "#7a83ae",
"stroke-width": 0.3,
"text-shadow": "black 0px 0px 0.1px",
+ "letter-spacing": 0,
"data-size": 14,
"font-size": 14,
"font-family": "Courier New",
@@ -394,7 +397,8 @@
"fill": "#3e3e4b",
"stroke": "#3a3a3a",
"stroke-width": 0,
- "text-shadow": "white 0px 0px 4px",
+ "text-shadow": "black 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Almendra SC",
diff --git a/styles/pale.json b/styles/pale.json
index 312009b9..8e839600 100644
--- a/styles/pale.json
+++ b/styles/pale.json
@@ -332,6 +332,7 @@
"opacity": 0.8,
"fill": "#3a3a3a",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 7,
"font-size": 7,
"font-family": "Arima Madurai"
@@ -357,6 +358,7 @@
"opacity": 0.8,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 4,
"font-size": 4,
"font-family": "Arima Madurai"
@@ -384,6 +386,7 @@
"stroke": "#000000",
"stroke-width": 0,
"text-shadow": "white 0px 0px 6px",
+ "letter-spacing": 0,
"data-size": 14,
"font-size": 14,
"font-family": "Arima Madurai",
@@ -395,6 +398,7 @@
"stroke": "#701b05",
"stroke-width": 0.1,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 6,
"font-size": 6,
"font-family": "Arima Madurai",
diff --git a/styles/watercolor.json b/styles/watercolor.json
index 79cc9484..982c1b49 100644
--- a/styles/watercolor.json
+++ b/styles/watercolor.json
@@ -332,6 +332,7 @@
"opacity": 1,
"fill": "#043449",
"text-shadow": "white 0px 0px 2px",
+ "letter-spacing": 0,
"data-size": 5,
"font-size": 5,
"font-family": "Comfortaa"
@@ -357,6 +358,7 @@
"opacity": 1,
"fill": "#3e3e4b",
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 3,
"font-size": 3,
"font-family": "Comfortaa"
@@ -384,6 +386,7 @@
"stroke": "#000000",
"stroke-width": 0.15,
"text-shadow": "black 1px 1px 3px",
+ "letter-spacing": 0,
"data-size": 18,
"font-size": 18,
"font-family": "Gloria Hallelujah",
@@ -395,6 +398,7 @@
"stroke": "#3a3a3a",
"stroke-width": 0,
"text-shadow": "white 0px 0px 4px",
+ "letter-spacing": 0,
"data-size": 16,
"font-size": 16,
"font-family": "Comfortaa",
diff --git a/utils/pathUtils.js b/utils/pathUtils.js
new file mode 100644
index 00000000..ff3bbf2a
--- /dev/null
+++ b/utils/pathUtils.js
@@ -0,0 +1,155 @@
+"use strict";
+
+// get continuous paths for all cells at once based on getType(cellId) comparison
+function getVertexPaths({getType, options}) {
+ const {cells, vertices} = pack;
+ const paths = {};
+
+ const checkedCells = new Uint8Array(cells.c.length);
+ const addToChecked = cellId => (checkedCells[cellId] = 1);
+ const isChecked = cellId => checkedCells[cellId] === 1;
+
+ for (let cellId = 0; cellId < cells.c.length; cellId++) {
+ if (isChecked(cellId) || getType(cellId) === 0) continue;
+ addToChecked(cellId);
+
+ const type = getType(cellId);
+ const ofSameType = cellId => getType(cellId) === type;
+ const ofDifferentType = cellId => getType(cellId) !== type;
+
+ const onborderCell = cells.c[cellId].find(ofDifferentType);
+ if (onborderCell === undefined) continue;
+
+ const feature = pack.features[cells.f[onborderCell]];
+ if (feature.type === "lake" && feature.shoreline.every(ofSameType)) continue; // inner lake
+
+ const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
+ if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
+
+ const vertexChain = connectVertices({startingVertex, ofSameType, addToChecked, closeRing: true});
+ if (vertexChain.length < 3) continue;
+
+ addPath(type, vertexChain);
+ }
+
+ return Object.entries(paths);
+
+ function getBorderPath(vertexChain, discontinue) {
+ let discontinued = true;
+ let lastOperation = "";
+ const path = vertexChain.map(vertex => {
+ if (discontinue(vertex)) {
+ discontinued = true;
+ return "";
+ }
+
+ const operation = discontinued ? "M" : "L";
+ const command = operation === lastOperation ? "" : operation;
+
+ discontinued = false;
+ lastOperation = operation;
+
+ return ` ${command}${getVertexPoint(vertex)}`;
+ });
+
+ return path.join("").trim();
+ }
+
+ function isBorderVertex(vertex) {
+ const adjacentCells = vertices.c[vertex];
+ return adjacentCells.some(i => cells.b[i]);
+ }
+
+ function isLandVertex(vertex) {
+ const adjacentCells = vertices.c[vertex];
+ return adjacentCells.every(i => cells.h[i] >= MIN_LAND_HEIGHT);
+ }
+
+ function addPath(index, vertexChain) {
+ if (!paths[index]) paths[index] = {fill: "", waterGap: "", halo: ""};
+ if (options.fill) paths[index].fill += getFillPath(vertexChain);
+ if (options.halo) paths[index].halo += getBorderPath(vertexChain, isBorderVertex);
+ if (options.waterGap) paths[index].waterGap += getBorderPath(vertexChain, isLandVertex);
+ }
+}
+
+function getVertexPoint(vertexId) {
+ return pack.vertices.p[vertexId];
+}
+
+function getFillPath(vertexChain) {
+ const points = vertexChain.map(getVertexPoint);
+ const firstPoint = points.shift();
+ return `M${firstPoint} L${points.join(" ")}`;
+}
+
+// get single path for an non-continuous array of cells
+function getVertexPath(cellsArray) {
+ const {cells, vertices} = pack;
+
+ const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true]));
+ const ofSameType = cellId => cellsObj[cellId];
+ const ofDifferentType = cellId => !cellsObj[cellId];
+
+ const checkedCells = new Uint8Array(cells.c.length);
+ const addToChecked = cellId => (checkedCells[cellId] = 1);
+ const isChecked = cellId => checkedCells[cellId] === 1;
+
+ let path = "";
+
+ for (const cellId of cellsArray) {
+ if (isChecked(cellId)) continue;
+
+ const onborderCell = cells.c[cellId].find(ofDifferentType);
+ if (onborderCell === undefined) continue;
+
+ const feature = pack.features[cells.f[onborderCell]];
+ if (feature.type === "lake" && feature.shoreline.every(ofSameType)) continue; // inner lake
+
+ const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
+ if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
+
+ const vertexChain = connectVertices({startingVertex, ofSameType, addToChecked, closeRing: true});
+ if (vertexChain.length < 3) continue;
+
+ path += getFillPath(vertexChain);
+ }
+
+ return path;
+}
+
+function connectVertices({startingVertex, ofSameType, addToChecked, closeRing}) {
+ const vertices = pack.vertices;
+ const MAX_ITERATIONS = pack.cells.i.length;
+ const chain = []; // vertices chain to form a path
+
+ let next = startingVertex;
+ for (let i = 0; i === 0 || next !== startingVertex; i++) {
+ const previous = chain.at(-1);
+ const current = next;
+ chain.push(current);
+
+ const neibCells = vertices.c[current];
+ if (addToChecked) neibCells.filter(ofSameType).forEach(addToChecked);
+
+ const [c1, c2, c3] = neibCells.map(ofSameType);
+ const [v1, v2, v3] = vertices.v[current];
+
+ if (v1 !== previous && c1 !== c2) next = v1;
+ else if (v2 !== previous && c2 !== c3) next = v2;
+ else if (v3 !== previous && c1 !== c3) next = v3;
+
+ if (next === current) {
+ ERROR && console.error("ConnectVertices: next vertex is not found");
+ break;
+ }
+
+ if (i === MAX_ITERATIONS) {
+ ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS);
+ break;
+ }
+ }
+
+ if (closeRing) chain.push(startingVertex);
+ return chain;
+}
diff --git a/versioning.js b/versioning.js
index 72c8f037..5e8a9a8d 100644
--- a/versioning.js
+++ b/versioning.js
@@ -1,21 +1,26 @@
"use strict";
-
-// version and caching control
-const version = "1.99.14"; // generator version, update each time
+/**
+ * Version Control Guidelines
+ * --------------------------
+ * We use Semantic Versioning: major.minor.patch. Refer to https://semver.org
+ * Our .map file format is considered the public API.
+ *
+ * Update the version MANUALLY on each merge to main:
+ * 1. MAJOR version: Incompatible changes that break existing maps
+ * 2. MINOR version: Backwards-compatible changes requiring old .map files to be updated
+ * 3. PATCH version: Backwards-compatible bug fixes not affecting .map file format
+ *
+ * Example: 1.102.0 -> Major version 1, Minor version 102, Patch version 0
+ */
+const VERSION = "1.102.00";
{
- document.title += " v" + version;
+ document.title += " v" + VERSION;
const loadingScreenVersion = document.getElementById("versionText");
- if (loadingScreenVersion) loadingScreenVersion.innerText = `v${version}`;
+ if (loadingScreenVersion) loadingScreenVersion.innerText = `v${VERSION}`;
- const versionNumber = parseFloat(version);
- const storedVersion = localStorage.getItem("version") ? parseFloat(localStorage.getItem("version")) : 0;
-
- const isOutdated = storedVersion !== versionNumber;
- if (isOutdated) clearCache();
-
- const showUpdate = storedVersion < versionNumber;
- if (showUpdate) setTimeout(showUpdateWindow, 6000);
+ const storedVersion = localStorage.getItem("version");
+ if (compareVersions(storedVersion, VERSION, {patch: false}).isOlder) setTimeout(showUpdateWindow, 6000);
function showUpdateWindow() {
const changelog = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog";
@@ -23,11 +28,13 @@ const version = "1.99.14"; // generator version, update each time
const discord = "https://discordapp.com/invite/X7E84HU";
const patreon = "https://www.patreon.com/azgaar";
- alertMessage.innerHTML = /* html */ `The Fantasy Map Generator is updated up to version
${version}. This version is compatible with
previous versions, loaded save files will be auto-updated.
- ${storedVersion ? "
Reload the page to fetch fresh code." : ""}
+ alertMessage.innerHTML = /* html */ `The Fantasy Map Generator is updated up to version
${VERSION}. This version is compatible with
previous versions, loaded save files will be auto-updated.
+ ${storedVersion ? "
Click on OK and then reload the page to fetch fresh code." : ""}
Latest changes:
+ - Style: ability to set letter spacing
+ - Zones update
- Notes Editor: on-demand AI text generation
- New style preset: Dark Seas
- New routes generation algorithm
@@ -37,12 +44,6 @@ const version = "1.99.14"; // generator version, update each time
- Ability to render ocean heightmap
- Scale bar styling features
- Vignette visual layer and vignette styling options
- - Ability to define custom heightmap color scheme
- - New style preset Night and new heightmap color schemes
- - Random encounter markers (integration with Deorum)
- - Auto-load of the last saved map is now optional (see Onload behavior in Options)
- - New label placement algorithm for states
- - North and South Poles temperature can be set independently
Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.
@@ -51,15 +52,15 @@ const version = "1.99.14"; // generator version, update each time
const buttons = {
Ok: function () {
$(this).dialog("close");
- if (storedVersion) localStorage.clear();
- localStorage.setItem("version", version);
+ localStorage.setItem("version", VERSION);
}
};
if (storedVersion) {
- buttons.Reload = () => {
+ buttons.Cleanup = () => {
+ clearCache();
localStorage.clear();
- localStorage.setItem("version", version);
+ localStorage.setItem("version", VERSION);
location.reload();
};
}
@@ -75,6 +76,29 @@ const version = "1.99.14"; // generator version, update each time
async function clearCache() {
const cacheNames = await caches.keys();
- Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
+ return Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
}
}
+
+function isValidVersion(versionString) {
+ if (!versionString) return false;
+ const [major, minor, patch] = versionString.split(".");
+ return !isNaN(major) && !isNaN(minor) && !isNaN(patch);
+}
+
+function compareVersions(version1, version2, options = {major: true, minor: true, patch: true}) {
+ if (!isValidVersion(version1) || !isValidVersion(version2)) return {isEqual: false, isNewer: false, isOlder: false};
+
+ let [major1, minor1, patch1] = version1.split(".").map(Number);
+ let [major2, minor2, patch2] = version2.split(".").map(Number);
+
+ if (!options.major) major1 = major2 = 0;
+ if (!options.minor) minor1 = minor2 = 0;
+ if (!options.patch) patch1 = patch2 = 0;
+
+ const isEqual = major1 === major2 && minor1 === minor2 && patch1 === patch2;
+ const isNewer = major1 > major2 || (major1 === major2 && (minor1 > minor2 || (minor1 === minor2 && patch1 > patch2)));
+ const isOlder = major1 < major2 || (major1 === major2 && (minor1 < minor2 || (minor1 === minor2 && patch1 < patch2)));
+
+ return {isEqual, isNewer, isOlder};
+}