refactor(es modules): move all files to src, try vite 3.0

This commit is contained in:
Azgaar 2022-06-27 01:07:42 +03:00
parent 4feed39d5c
commit 0d05e1b250
119 changed files with 8218 additions and 139 deletions

View file

@ -0,0 +1,94 @@
import {rn} from "/src/utils/numberUtils";
export function handleZoom(isScaleChanged, isPositionChanged) {
viewbox.attr("transform", `translate(${viewX} ${viewY}) scale(${scale})`);
if (isPositionChanged) drawCoordinates();
if (isScaleChanged) {
invokeActiveZooming();
drawScaleBar(scale);
}
// zoom image converter overlay
if (customization === 1) {
const canvas = document.getElementById("canvas");
if (!canvas || canvas.style.opacity === "0") return;
const img = document.getElementById("imageToConvert");
if (!img) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scale, 0, 0, scale, viewX, viewY);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
}
// active zooming feature
export function invokeActiveZooming() {
if (coastline.select("#sea_island").size() && +coastline.select("#sea_island").attr("auto-filter")) {
// toggle shade/blur filter for coatline on zoom
const filter = scale > 1.5 && scale <= 2.6 ? null : scale > 2.6 ? "url(#blurFilter)" : "url(#dropShadow)";
coastline.select("#sea_island").attr("filter", filter);
}
// rescale labels on zoom
if (labels.style("display") !== "none") {
labels.selectAll("g").each(function () {
if (this.id === "burgLabels") return;
const desired = +this.dataset.size;
const relative = Math.max(rn((desired + desired / scale) / 2, 2), 1);
if (rescaleLabels.checked) this.setAttribute("font-size", relative);
const hidden = hideLabels.checked && (relative * scale < 6 || relative * scale > 60);
if (hidden) this.classList.add("hidden");
else this.classList.remove("hidden");
});
}
// rescale emblems on zoom
if (emblems.style("display") !== "none") {
emblems.selectAll("g").each(function () {
const size = this.getAttribute("font-size") * scale;
const hidden = hideEmblems.checked && (size < 25 || size > 300);
if (hidden) this.classList.add("hidden");
else this.classList.remove("hidden");
if (!hidden && window.COArenderer && this.children.length && !this.children[0].getAttribute("href"))
renderGroupCOAs(this);
});
}
// turn off ocean pattern if scale is big (improves performance)
oceanPattern
.select("rect")
.attr("fill", scale > 10 ? "#fff" : "url(#oceanic)")
.attr("opacity", scale > 10 ? 0.2 : null);
// change states halo width
if (!customization) {
const desired = +statesHalo.attr("data-width");
const haloSize = rn(desired / scale ** 0.8, 2);
statesHalo.attr("stroke-width", haloSize).style("display", haloSize > 0.1 ? "block" : "none");
}
// rescale map markers
+markers.attr("rescale") &&
pack.markers?.forEach(marker => {
const {i, x, y, size = 30, hidden} = marker;
const el = !hidden && document.getElementById(`marker${i}`);
if (!el) return;
const zoomedSize = Math.max(rn(size / 5 + 24 / scale, 2), 1);
el.setAttribute("width", zoomedSize);
el.setAttribute("height", zoomedSize);
el.setAttribute("x", rn(x - zoomedSize / 2, 1));
el.setAttribute("y", rn(y - zoomedSize, 1));
});
// rescale rulers to have always the same size
if (ruler.style("display") !== "none") {
const size = rn((10 / scale ** 0.3) * 2, 2);
ruler.selectAll("text").attr("font-size", size);
}
}

76
src/modules/biomes.js Normal file
View file

@ -0,0 +1,76 @@
window.Biomes = (function () {
const getDefault = () => {
const name = [
"Marine",
"Hot desert",
"Cold desert",
"Savanna",
"Grassland",
"Tropical seasonal forest",
"Temperate deciduous forest",
"Tropical rainforest",
"Temperate rainforest",
"Taiga",
"Tundra",
"Glacier",
"Wetland"
];
const color = [
"#466eab",
"#fbe79f",
"#b5b887",
"#d2d082",
"#c8d68f",
"#b6d95d",
"#29bc56",
"#7dcb35",
"#409c43",
"#4b6b32",
"#96784b",
"#d5e7eb",
"#0b9131"
];
const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12];
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 150];
const icons = [
{},
{dune: 3, cactus: 6, deadTree: 1},
{dune: 9, deadTree: 1},
{acacia: 1, grass: 9},
{grass: 1},
{acacia: 8, palm: 1},
{deciduous: 1},
{acacia: 5, palm: 3, deciduous: 1, swamp: 1},
{deciduous: 6, swamp: 1},
{conifer: 1},
{grass: 1},
{},
{swamp: 1}
];
const cost = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost
const biomesMartix = [
// hot ↔ cold [>19°C; <-4°C]; dry ↕ wet
new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]),
new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]),
new Uint8Array([5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, 10, 10]),
new Uint8Array([5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10]),
new Uint8Array([7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10, 10])
];
// parse icons weighted array into a simple array
for (let i = 0; i < icons.length; i++) {
const parsed = [];
for (const icon in icons[i]) {
for (let j = 0; j < icons[i][icon]; j++) {
parsed.push(icon);
}
}
icons[i] = parsed;
}
return {i: d3.range(0, name.length), name, color, biomesMartix, habitability, iconsDensity, icons, cost};
};
return {getDefault};
})();

File diff suppressed because it is too large Load diff

1056
src/modules/coa-generator.js Normal file

File diff suppressed because it is too large Load diff

2020
src/modules/coa-renderer.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,562 @@
import {TIME} from "/src/config/logging";
import {getColors} from "/src/utils/colorUtils";
import {rn, minmax} from "/src/utils/numberUtils";
import {rand, P, rw, biased} from "/src/utils/probabilityUtils";
import {abbreviate} from "/src/utils/languageUtils";
window.Cultures = (function () {
let cells;
const generate = function () {
TIME && console.time("generateCultures");
cells = pack.cells;
cells.culture = new Uint16Array(cells.i.length); // cell cultures
let count = Math.min(+culturesInput.value, +culturesSet.selectedOptions[0].dataset.max);
const populated = cells.i.filter(i => cells.s[i]); // populated cells
if (populated.length < count * 25) {
count = Math.floor(populated.length / 50);
if (!count) {
WARN && console.warn(`There are no populated cells. Cannot generate cultures`);
pack.cultures = [{name: "Wildlands", i: 0, base: 1, shield: "round"}];
alertMessage.innerHTML = /* html */ ` The climate is harsh and people cannot live in this world.<br />
No cultures, states and burgs will be created.<br />
Please consider changing climate settings in the World Configurator`;
$("#alert").dialog({
resizable: false,
title: "Extreme climate warning",
buttons: {
Ok: function () {
$(this).dialog("close");
}
}
});
return;
} else {
WARN && console.warn(`Not enough populated cells (${populated.length}). Will generate only ${count} cultures`);
alertMessage.innerHTML = /* html */ ` There are only ${populated.length} populated cells and it's insufficient livable area.<br />
Only ${count} out of ${culturesInput.value} requested cultures will be generated.<br />
Please consider changing climate settings in the World Configurator`;
$("#alert").dialog({
resizable: false,
title: "Extreme climate warning",
buttons: {
Ok: function () {
$(this).dialog("close");
}
}
});
}
}
const cultures = (pack.cultures = selectCultures(count));
const centers = d3.quadtree();
const colors = getColors(count);
const emblemShape = document.getElementById("emblemShape").value;
const codes = [];
cultures.forEach(function (c, i) {
const cell = (c.center = placeCenter(c.sort ? c.sort : i => cells.s[i]));
centers.add(cells.p[cell]);
c.i = i + 1;
delete c.odd;
delete c.sort;
c.color = colors[i];
c.type = defineCultureType(cell);
c.expansionism = defineCultureExpansionism(c.type);
c.origins = [0];
c.code = abbreviate(c.name, codes);
codes.push(c.code);
cells.culture[cell] = i + 1;
if (emblemShape === "random") c.shield = getRandomShield();
});
function placeCenter(v) {
let c,
spacing = (graphWidth + graphHeight) / 2 / count;
const sorted = [...populated].sort((a, b) => v(b) - v(a)),
max = Math.floor(sorted.length / 2);
do {
c = sorted[biased(0, max, 5)];
spacing *= 0.9;
} while (centers.find(cells.p[c][0], cells.p[c][1], spacing) !== undefined);
return c;
}
// the first culture with id 0 is for wildlands
cultures.unshift({name: "Wildlands", i: 0, base: 1, origins: [null], shield: "round"});
// make sure all bases exist in nameBases
if (!nameBases.length) {
ERROR && console.error("Name base is empty, default nameBases will be applied");
nameBases = Names.getNameBases();
}
cultures.forEach(c => (c.base = c.base % nameBases.length));
function selectCultures(c) {
let def = getDefault(c);
if (c === def.length) return def;
if (def.every(d => d.odd === 1)) return def.splice(0, c);
const count = Math.min(c, def.length);
const cultures = [];
for (let culture, rnd, i = 0; cultures.length < count && i < 200; i++) {
do {
rnd = rand(def.length - 1);
culture = def[rnd];
} while (!P(culture.odd));
cultures.push(culture);
def.splice(rnd, 1);
}
return cultures;
}
// set culture type based on culture center position
function defineCultureType(i) {
if (cells.h[i] < 70 && [1, 2, 4].includes(cells.biome[i])) return "Nomadic"; // high penalty in forest biomes and near coastline
if (cells.h[i] > 50) return "Highland"; // no penalty for hills and moutains, high for other elevations
const f = pack.features[cells.f[cells.haven[i]]]; // opposite feature
if (f.type === "lake" && f.cells > 5) return "Lake"; // low water cross penalty and high for growth not along coastline
if (
(cells.harbor[i] && f.type !== "lake" && P(0.1)) ||
(cells.harbor[i] === 1 && P(0.6)) ||
(pack.features[cells.f[i]].group === "isle" && P(0.4))
)
return "Naval"; // low water cross penalty and high for non-along-coastline growth
if (cells.r[i] && cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth
if (cells.t[i] > 2 && [3, 7, 8, 9, 10, 12].includes(cells.biome[i])) return "Hunting"; // high penalty in non-native biomes
return "Generic";
}
function defineCultureExpansionism(type) {
let base = 1; // Generic
if (type === "Lake") base = 0.8;
else if (type === "Naval") base = 1.5;
else if (type === "River") base = 0.9;
else if (type === "Nomadic") base = 1.5;
else if (type === "Hunting") base = 0.7;
else if (type === "Highland") base = 1.2;
return rn(((Math.random() * powerInput.value) / 2 + 1) * base, 1);
}
TIME && console.timeEnd("generateCultures");
};
const add = function (center) {
const defaultCultures = getDefault();
let culture, base, name;
if (pack.cultures.length < defaultCultures.length) {
// add one of the default cultures
culture = pack.cultures.length;
base = defaultCultures[culture].base;
name = defaultCultures[culture].name;
} else {
// add random culture besed on one of the current ones
culture = rand(pack.cultures.length - 1);
name = Names.getCulture(culture, 5, 8, "");
base = pack.cultures[culture].base;
}
const code = abbreviate(
name,
pack.cultures.map(c => c.code)
);
const i = pack.cultures.length;
const color = d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
// define emblem shape
let shield = culture.shield;
const emblemShape = document.getElementById("emblemShape").value;
if (emblemShape === "random") shield = getRandomShield();
pack.cultures.push({
name,
color,
base,
center,
i,
expansionism: 1,
type: "Generic",
cells: 0,
area: 0,
rural: 0,
urban: 0,
origins: [0],
code,
shield
});
};
const getDefault = function (count) {
// generic sorting functions
const cells = pack.cells,
s = cells.s,
sMax = d3.max(s),
t = cells.t,
h = cells.h,
temp = grid.cells.temp;
const n = cell => Math.ceil((s[cell] / sMax) * 3); // normalized cell score
const td = (cell, goal) => {
const d = Math.abs(temp[cells.g[cell]] - goal);
return d ? d + 1 : 1;
}; // temperature difference fee
const bd = (cell, biomes, fee = 4) => (biomes.includes(cells.biome[cell]) ? 1 : fee); // biome difference fee
const sf = (cell, fee = 4) =>
cells.haven[cell] && pack.features[cells.f[cells.haven[cell]]].type !== "lake" ? 1 : fee; // not on sea coast fee
if (culturesSet.value === "european") {
return [
{name: "Shwazen", base: 0, odd: 1, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "swiss"},
{name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "wedged"},
{name: "Luari", base: 2, odd: 1, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "french"},
{name: "Tallian", base: 3, odd: 1, sort: i => n(i) / td(i, 15), shield: "horsehead"},
{name: "Astellian", base: 4, odd: 1, sort: i => n(i) / td(i, 16), shield: "spanish"},
{name: "Slovan", base: 5, odd: 1, sort: i => (n(i) / td(i, 6)) * t[i], shield: "polish"},
{name: "Norse", base: 6, odd: 1, sort: i => n(i) / td(i, 5), shield: "heater"},
{name: "Elladan", base: 7, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"},
{name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 15) / t[i], shield: "roman"},
{name: "Soumi", base: 9, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"},
{name: "Portuzian", base: 13, odd: 1, sort: i => n(i) / td(i, 17) / sf(i), shield: "renaissance"},
{name: "Vengrian", base: 15, odd: 1, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "horsehead2"},
{name: "Turchian", base: 16, odd: 0.05, sort: i => n(i) / td(i, 14), shield: "round"},
{name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "oldFrench"},
{name: "Keltan", base: 22, odd: 0.05, sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], shield: "oval"}
];
}
if (culturesSet.value === "oriental") {
return [
{name: "Koryo", base: 10, odd: 1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"},
{name: "Hantzu", base: 11, odd: 1, sort: i => n(i) / td(i, 13), shield: "banner"},
{name: "Yamoto", base: 12, odd: 1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"},
{name: "Turchian", base: 16, odd: 1, sort: i => n(i) / td(i, 12), shield: "round"},
{
name: "Berberan",
base: 17,
odd: 0.2,
sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i],
shield: "oval"
},
{name: "Eurabic", base: 18, odd: 1, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "oval"},
{name: "Efratic", base: 23, odd: 0.1, sort: i => (n(i) / td(i, 22)) * t[i], shield: "round"},
{name: "Tehrani", base: 24, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"},
{name: "Maui", base: 25, odd: 0.2, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "vesicaPiscis"},
{name: "Carnatic", base: 26, odd: 0.5, sort: i => n(i) / td(i, 26), shield: "round"},
{name: "Vietic", base: 29, odd: 0.8, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"},
{name: "Guantzu", base: 30, odd: 0.5, sort: i => n(i) / td(i, 17), shield: "banner"},
{name: "Ulus", base: 31, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"}
];
}
if (culturesSet.value === "english") {
const getName = () => Names.getBase(1, 5, 9, "", 0);
return [
{name: getName(), base: 1, odd: 1, shield: "heater"},
{name: getName(), base: 1, odd: 1, shield: "wedged"},
{name: getName(), base: 1, odd: 1, shield: "swiss"},
{name: getName(), base: 1, odd: 1, shield: "oldFrench"},
{name: getName(), base: 1, odd: 1, shield: "swiss"},
{name: getName(), base: 1, odd: 1, shield: "spanish"},
{name: getName(), base: 1, odd: 1, shield: "hessen"},
{name: getName(), base: 1, odd: 1, shield: "fantasy5"},
{name: getName(), base: 1, odd: 1, shield: "fantasy4"},
{name: getName(), base: 1, odd: 1, shield: "fantasy1"}
];
}
if (culturesSet.value === "antique") {
return [
{name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"}, // Roman
{name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 15) / sf(i), shield: "roman"}, // Roman
{name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 16) / sf(i), shield: "roman"}, // Roman
{name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 17) / t[i], shield: "roman"}, // Roman
{name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"}, // Greek
{name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 19) / sf(i)) * h[i], shield: "boeotian"}, // Greek
{name: "Macedonian", base: 7, odd: 0.5, sort: i => (n(i) / td(i, 12)) * h[i], shield: "round"}, // Greek
{name: "Celtic", base: 22, odd: 1, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), shield: "round"},
{name: "Germanic", base: 0, odd: 1, sort: i => n(i) / td(i, 10) ** 0.5 / bd(i, [6, 8]), shield: "round"},
{name: "Persian", base: 24, odd: 0.8, sort: i => (n(i) / td(i, 18)) * h[i], shield: "oval"}, // Iranian
{name: "Scythian", base: 24, odd: 0.5, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [4]), shield: "round"}, // Iranian
{name: "Cantabrian", base: 20, odd: 0.5, sort: i => (n(i) / td(i, 16)) * h[i], shield: "oval"}, // Basque
{name: "Estian", base: 9, odd: 0.2, sort: i => (n(i) / td(i, 5)) * t[i], shield: "pavise"}, // Finnic
{name: "Carthaginian", base: 17, odd: 0.3, sort: i => n(i) / td(i, 19) / sf(i), shield: "oval"}, // Berber
{name: "Mesopotamian", base: 23, odd: 0.2, sort: i => n(i) / td(i, 22) / bd(i, [1, 2, 3]), shield: "oval"} // Mesopotamian
];
}
if (culturesSet.value === "highFantasy") {
return [
// fantasy races
{
name: "Quenian (Elfish)",
base: 33,
odd: 1,
sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i],
shield: "gondor"
}, // Elves
{
name: "Eldar (Elfish)",
base: 33,
odd: 1,
sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i],
shield: "noldor"
}, // Elves
{
name: "Trow (Dark Elfish)",
base: 34,
odd: 0.9,
sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i],
shield: "hessen"
}, // Dark Elves
{
name: "Lothian (Dark Elfish)",
base: 34,
odd: 0.3,
sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i],
shield: "wedged"
}, // Dark Elves
{name: "Dunirr (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "ironHills"}, // Dwarfs
{name: "Khazadur (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarfs
{name: "Kobold (Goblin)", base: 36, odd: 1, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin
{name: "Uruk (Orkish)", base: 37, odd: 1, sort: i => h[i] * t[i], shield: "urukHai"}, // Orc
{
name: "Ugluk (Orkish)",
base: 37,
odd: 0.5,
sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]),
shield: "moriaOrc"
}, // Orc
{name: "Yotunn (Giants)", base: 38, odd: 0.7, sort: i => td(i, -10), shield: "pavise"}, // Giant
{name: "Rake (Drakonic)", base: 39, odd: 0.7, sort: i => -s[i], shield: "fantasy2"}, // Draconic
{name: "Arago (Arachnid)", base: 40, odd: 0.7, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid
{name: "Aj'Snaga (Serpents)", base: 41, odd: 0.7, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"}, // Serpents
// fantasy human
{name: "Anor (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 10), shield: "fantasy5"},
{name: "Dail (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 13), shield: "roman"},
{name: "Rohand (Human)", base: 16, odd: 1, sort: i => n(i) / td(i, 16), shield: "round"},
{
name: "Dulandir (Human)",
base: 31,
odd: 1,
sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i],
shield: "easterling"
}
];
}
if (culturesSet.value === "darkFantasy") {
return [
// common real-world English
{name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"},
{name: "Enlandic", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"},
{name: "Westen", base: 1, odd: 1, sort: i => n(i) / td(i, 10), shield: "heater"},
{name: "Nortumbic", base: 1, odd: 1, sort: i => n(i) / td(i, 7), shield: "heater"},
{name: "Mercian", base: 1, odd: 1, sort: i => n(i) / td(i, 9), shield: "heater"},
{name: "Kentian", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"},
// rare real-world western
{name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5) / sf(i), shield: "oldFrench"},
{name: "Schwarzen", base: 0, odd: 0.3, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "gonfalon"},
{name: "Luarian", base: 2, odd: 0.3, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"},
{name: "Hetallian", base: 3, odd: 0.3, sort: i => n(i) / td(i, 15), shield: "oval"},
{name: "Astellian", base: 4, odd: 0.3, sort: i => n(i) / td(i, 16), shield: "spanish"},
// rare real-world exotic
{
name: "Kiswaili",
base: 28,
odd: 0.05,
sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]),
shield: "vesicaPiscis"
},
{name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"},
{name: "Koryo", base: 10, odd: 0.05, sort: i => n(i) / td(i, 12) / t[i], shield: "round"},
{name: "Hantzu", base: 11, odd: 0.05, sort: i => n(i) / td(i, 13), shield: "banner"},
{name: "Yamoto", base: 12, odd: 0.05, sort: i => n(i) / td(i, 15) / t[i], shield: "round"},
{name: "Guantzu", base: 30, odd: 0.05, sort: i => n(i) / td(i, 17), shield: "banner"},
{
name: "Ulus",
base: 31,
odd: 0.05,
sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i],
shield: "banner"
},
{name: "Turan", base: 16, odd: 0.05, sort: i => n(i) / td(i, 12), shield: "round"},
{
name: "Berberan",
base: 17,
odd: 0.05,
sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i],
shield: "round"
},
{
name: "Eurabic",
base: 18,
odd: 0.05,
sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i],
shield: "round"
},
{name: "Slovan", base: 5, odd: 0.05, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"},
{
name: "Keltan",
base: 22,
odd: 0.1,
sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]),
shield: "vesicaPiscis"
},
{name: "Elladan", base: 7, odd: 0.2, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"},
{name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"},
// fantasy races
{name: "Eldar", base: 33, odd: 0.5, sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], shield: "fantasy5"}, // Elves
{name: "Trow", base: 34, odd: 0.8, sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], shield: "hessen"}, // Dark Elves
{name: "Durinn", base: 35, odd: 0.8, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarven
{name: "Kobblin", base: 36, odd: 0.8, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin
{name: "Uruk", base: 37, odd: 0.8, sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), shield: "urukHai"}, // Orc
{name: "Yotunn", base: 38, odd: 0.8, sort: i => td(i, -10), shield: "pavise"}, // Giant
{name: "Drake", base: 39, odd: 0.9, sort: i => -s[i], shield: "fantasy2"}, // Draconic
{name: "Rakhnid", base: 40, odd: 0.9, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid
{name: "Aj'Snaga", base: 41, odd: 0.9, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"} // Serpents
];
}
if (culturesSet.value === "random") {
return d3.range(count).map(function () {
const rnd = rand(nameBases.length - 1);
const name = Names.getBaseShort(rnd);
return {name, base: rnd, odd: 1, shield: getRandomShield()};
});
}
// all-world
return [
{name: "Shwazen", base: 0, odd: 0.7, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "hessen"},
{name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"},
{name: "Luari", base: 2, odd: 0.6, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"},
{name: "Tallian", base: 3, odd: 0.6, sort: i => n(i) / td(i, 15), shield: "horsehead2"},
{name: "Astellian", base: 4, odd: 0.6, sort: i => n(i) / td(i, 16), shield: "spanish"},
{name: "Slovan", base: 5, odd: 0.7, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"},
{name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5), shield: "heater"},
{name: "Elladan", base: 7, odd: 0.7, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"},
{name: "Romian", base: 8, odd: 0.7, sort: i => n(i) / td(i, 15), shield: "roman"},
{name: "Soumi", base: 9, odd: 0.3, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"},
{name: "Koryo", base: 10, odd: 0.1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"},
{name: "Hantzu", base: 11, odd: 0.1, sort: i => n(i) / td(i, 13), shield: "banner"},
{name: "Yamoto", base: 12, odd: 0.1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"},
{name: "Portuzian", base: 13, odd: 0.4, sort: i => n(i) / td(i, 17) / sf(i), shield: "spanish"},
{name: "Nawatli", base: 14, odd: 0.1, sort: i => h[i] / td(i, 18) / bd(i, [7]), shield: "square"},
{name: "Vengrian", base: 15, odd: 0.2, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "wedged"},
{name: "Turchian", base: 16, odd: 0.2, sort: i => n(i) / td(i, 13), shield: "round"},
{
name: "Berberan",
base: 17,
odd: 0.1,
sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i],
shield: "round"
},
{name: "Eurabic", base: 18, odd: 0.2, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "round"},
{name: "Inuk", base: 19, odd: 0.05, sort: i => td(i, -1) / bd(i, [10, 11]) / sf(i), shield: "square"},
{name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "spanish"},
{name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"},
{
name: "Keltan",
base: 22,
odd: 0.05,
sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i],
shield: "vesicaPiscis"
},
{name: "Efratic", base: 23, odd: 0.05, sort: i => (n(i) / td(i, 22)) * t[i], shield: "diamond"},
{name: "Tehrani", base: 24, odd: 0.1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"},
{name: "Maui", base: 25, odd: 0.05, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "round"},
{name: "Carnatic", base: 26, odd: 0.05, sort: i => n(i) / td(i, 26), shield: "round"},
{name: "Inqan", base: 27, odd: 0.05, sort: i => h[i] / td(i, 13), shield: "square"},
{name: "Kiswaili", base: 28, odd: 0.1, sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), shield: "vesicaPiscis"},
{name: "Vietic", base: 29, odd: 0.1, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"},
{name: "Guantzu", base: 30, odd: 0.1, sort: i => n(i) / td(i, 17), shield: "banner"},
{name: "Ulus", base: 31, odd: 0.1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"}
];
};
// expand cultures across the map (Dijkstra-like algorithm)
const expand = function () {
TIME && console.time("expandCultures");
cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
pack.cultures.forEach(function (c) {
if (!c.i || c.removed) return;
queue.queue({e: c.center, p: 0, c: c.i});
});
const neutral = (cells.i.length / 5000) * 3000 * neutralInput.value; // limit cost for culture growth
const cost = [];
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p,
c = next.c;
const type = pack.cultures[c].type;
cells.c[n].forEach(function (e) {
const biome = cells.biome[e];
const biomeCost = getBiomeCost(c, biome, type);
const biomeChangeCost = biome === cells.biome[n] ? 0 : 20; // penalty on biome change
const heightCost = getHeightCost(e, cells.h[e], type);
const riverCost = getRiverCost(cells.r[e], e, type);
const typeCost = getTypeCost(cells.t[e], type);
const totalCost =
p + (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / pack.cultures[c].expansionism;
if (totalCost > neutral) return;
if (!cost[e] || totalCost < cost[e]) {
if (cells.s[e] > 0) cells.culture[e] = c; // assign culture to populated cell
cost[e] = totalCost;
queue.queue({e, p: totalCost, c});
}
});
}
TIME && console.timeEnd("expandCultures");
};
function getBiomeCost(c, biome, type) {
if (cells.biome[pack.cultures[c].center] === biome) return 10; // tiny penalty for native biome
if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters
if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 10; // forest biome penalty for nomads
return biomesData.cost[biome] * 2; // general non-native biome penalty
}
function getHeightCost(i, h, type) {
const f = pack.features[cells.f[i]],
a = cells.area[i];
if (type === "Lake" && f.type === "lake") return 10; // no lake crossing penalty for Lake cultures
if (type === "Naval" && h < 20) return a * 2; // low sea/lake crossing penalty for Naval cultures
if (type === "Nomadic" && h < 20) return a * 50; // giant sea/lake crossing penalty for Nomads
if (h < 20) return a * 6; // general sea/lake crossing penalty
if (type === "Highland" && h < 44) return 3000; // giant penalty for highlanders on lowlands
if (type === "Highland" && h < 62) return 200; // giant penalty for highlanders on lowhills
if (type === "Highland") return 0; // no penalty for highlanders on highlands
if (h >= 67) return 200; // general mountains crossing penalty
if (h >= 44) return 30; // general hills crossing penalty
return 0;
}
function getRiverCost(r, i, type) {
if (type === "River") return r ? 0 : 100; // penalty for river cultures
if (!r) return 0; // no penalty for others if there is no river
return minmax(cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux
}
function getTypeCost(t, type) {
if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline
if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads
if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals
return 0;
}
const getRandomShield = function () {
const type = rw(COA.shields.types);
return rw(COA.shields[type]);
};
return {generate, add, expand, getDefault, getRandomShield};
})();

View file

@ -0,0 +1,32 @@
"use strict";
// define global vabiable, each to be refactored and de-globalized 1-by-1
let grid = {}; // initial graph based on jittered square grid and data
let pack = {}; // packed graph and data
let seed;
let mapId;
let mapHistory = [];
let elSelected;
let notes = [];
let customization = 0;
let rulers;
let biomesData;
let nameBases;
let color;
let lineGen;
// defined in main.js
let graphWidth;
let graphHeight;
let svgWidth;
let svgHeight;
let options = {};
let populationRate;
let distanceScale;
let urbanization;
let urbanDensity;
let statesNeutral;

170
src/modules/define-svg.js Normal file
View file

@ -0,0 +1,170 @@
"use strict";
// temporary define svg elements as globals
let svg,
defs,
viewbox,
scaleBar,
legend,
ocean,
oceanLayers,
oceanPattern,
lakes,
landmass,
texture,
terrs,
biomes,
cells,
gridOverlay,
coordinates,
compass,
rivers,
terrain,
relig,
cults,
regions,
statesBody,
statesHalo,
provs,
zones,
borders,
stateBorders,
provinceBorders,
routes,
roads,
trails,
searoutes,
temperature,
coastline,
ice,
prec,
population,
emblems,
labels,
icons,
burgLabels,
burgIcons,
anchors,
armies,
markers,
fogging,
ruler,
debug;
function defineSvg(width, height) {
// append svg layers (in default order)
svg = d3.select("#map");
defs = svg.select("#deftemp");
viewbox = svg.select("#viewbox");
scaleBar = svg.select("#scaleBar");
legend = svg.append("g").attr("id", "legend");
ocean = viewbox.append("g").attr("id", "ocean");
oceanLayers = ocean.append("g").attr("id", "oceanLayers");
oceanPattern = ocean.append("g").attr("id", "oceanPattern");
lakes = viewbox.append("g").attr("id", "lakes");
landmass = viewbox.append("g").attr("id", "landmass");
texture = viewbox.append("g").attr("id", "texture");
terrs = viewbox.append("g").attr("id", "terrs");
biomes = viewbox.append("g").attr("id", "biomes");
cells = viewbox.append("g").attr("id", "cells");
gridOverlay = viewbox.append("g").attr("id", "gridOverlay");
coordinates = viewbox.append("g").attr("id", "coordinates");
compass = viewbox.append("g").attr("id", "compass");
rivers = viewbox.append("g").attr("id", "rivers");
terrain = viewbox.append("g").attr("id", "terrain");
relig = viewbox.append("g").attr("id", "relig");
cults = viewbox.append("g").attr("id", "cults");
regions = viewbox.append("g").attr("id", "regions");
statesBody = regions.append("g").attr("id", "statesBody");
statesHalo = regions.append("g").attr("id", "statesHalo");
provs = viewbox.append("g").attr("id", "provs");
zones = viewbox.append("g").attr("id", "zones").style("display", "none");
borders = viewbox.append("g").attr("id", "borders");
stateBorders = borders.append("g").attr("id", "stateBorders");
provinceBorders = borders.append("g").attr("id", "provinceBorders");
routes = viewbox.append("g").attr("id", "routes");
roads = routes.append("g").attr("id", "roads");
trails = routes.append("g").attr("id", "trails");
searoutes = routes.append("g").attr("id", "searoutes");
temperature = viewbox.append("g").attr("id", "temperature");
coastline = viewbox.append("g").attr("id", "coastline");
ice = viewbox.append("g").attr("id", "ice").style("display", "none");
prec = viewbox.append("g").attr("id", "prec").style("display", "none");
population = viewbox.append("g").attr("id", "population");
emblems = viewbox.append("g").attr("id", "emblems").style("display", "none");
labels = viewbox.append("g").attr("id", "labels");
icons = viewbox.append("g").attr("id", "icons");
burgIcons = icons.append("g").attr("id", "burgIcons");
anchors = icons.append("g").attr("id", "anchors");
armies = viewbox.append("g").attr("id", "armies").style("display", "none");
markers = viewbox.append("g").attr("id", "markers");
fogging = viewbox
.append("g")
.attr("id", "fogging-cont")
.attr("mask", "url(#fog)")
.append("g")
.attr("id", "fogging")
.style("display", "none");
ruler = viewbox.append("g").attr("id", "ruler").style("display", "none");
debug = viewbox.append("g").attr("id", "debug");
// lake and coast groups
lakes.append("g").attr("id", "freshwater");
lakes.append("g").attr("id", "salt");
lakes.append("g").attr("id", "sinkhole");
lakes.append("g").attr("id", "frozen");
lakes.append("g").attr("id", "lava");
lakes.append("g").attr("id", "dry");
coastline.append("g").attr("id", "sea_island");
coastline.append("g").attr("id", "lake_island");
labels.append("g").attr("id", "states");
labels.append("g").attr("id", "addedLabels");
burgLabels = labels.append("g").attr("id", "burgLabels");
burgIcons.append("g").attr("id", "cities");
burgLabels.append("g").attr("id", "cities");
anchors.append("g").attr("id", "cities");
burgIcons.append("g").attr("id", "towns");
burgLabels.append("g").attr("id", "towns");
anchors.append("g").attr("id", "towns");
// population groups
population.append("g").attr("id", "rural");
population.append("g").attr("id", "urban");
// emblem groups
emblems.append("g").attr("id", "burgEmblems");
emblems.append("g").attr("id", "provinceEmblems");
emblems.append("g").attr("id", "stateEmblems");
// fogging
fogging.append("rect").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%");
fogging
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "#e8f0f6")
.attr("filter", "url(#splotch)");
landmass.append("rect").attr("x", 0).attr("y", 0).attr("width", width).attr("height", height);
oceanPattern
.append("rect")
.attr("fill", "url(#oceanic)")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height);
oceanLayers
.append("rect")
.attr("id", "oceanBase")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height);
}

View file

@ -0,0 +1,635 @@
import {findCell} from "/src/utils/graphUtils";
import {rn} from "/src/utils/numberUtils";
import {rand, P, rw} from "/src/utils/probabilityUtils";
import {parseTransform} from "/src/utils/stringUtils";
// update old .map version to the current one
export function resolveVersionConflicts(version) {
if (version < 1) {
// v1.0 added a new religions layer
relig = viewbox.insert("g", "#terrain").attr("id", "relig");
Religions.generate();
// v1.0 added a legend box
legend = svg.append("g").attr("id", "legend");
legend
.attr("font-family", "Almendra SC")
.attr("font-size", 13)
.attr("data-size", 13)
.attr("data-x", 99)
.attr("data-y", 93)
.attr("stroke-width", 2.5)
.attr("stroke", "#812929")
.attr("stroke-dasharray", "0 4 10 4")
.attr("stroke-linecap", "round");
// v1.0 separated drawBorders fron drawStates()
stateBorders = borders.append("g").attr("id", "stateBorders");
provinceBorders = borders.append("g").attr("id", "provinceBorders");
borders
.attr("opacity", null)
.attr("stroke", null)
.attr("stroke-width", null)
.attr("stroke-dasharray", null)
.attr("stroke-linecap", null)
.attr("filter", null);
stateBorders
.attr("opacity", 0.8)
.attr("stroke", "#56566d")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "2")
.attr("stroke-linecap", "butt");
provinceBorders
.attr("opacity", 0.8)
.attr("stroke", "#56566d")
.attr("stroke-width", 0.5)
.attr("stroke-dasharray", "1")
.attr("stroke-linecap", "butt");
// v1.0 added state relations, provinces, forms and full names
provs = viewbox.insert("g", "#borders").attr("id", "provs").attr("opacity", 0.6);
BurgsAndStates.collectStatistics();
BurgsAndStates.generateCampaigns();
BurgsAndStates.generateDiplomacy();
BurgsAndStates.defineStateForms();
drawStates();
BurgsAndStates.generateProvinces();
drawBorders();
if (!layerIsOn("toggleBorders")) $("#borders").fadeOut();
if (!layerIsOn("toggleStates")) regions.attr("display", "none").selectAll("path").remove();
// v1.0 added zones layer
zones = viewbox.insert("g", "#borders").attr("id", "zones").attr("display", "none");
zones
.attr("opacity", 0.6)
.attr("stroke", null)
.attr("stroke-width", 0)
.attr("stroke-dasharray", null)
.attr("stroke-linecap", "butt");
addZones();
if (!markers.selectAll("*").size()) {
Markers.generate();
turnButtonOn("toggleMarkers");
}
// v1.0 add fogging layer (state focus)
fogging = viewbox
.insert("g", "#ruler")
.attr("id", "fogging-cont")
.attr("mask", "url(#fog)")
.append("g")
.attr("id", "fogging")
.style("display", "none");
fogging.append("rect").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%");
defs
.append("mask")
.attr("id", "fog")
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "white");
// v1.0 changes states opacity bask to regions level
if (statesBody.attr("opacity")) {
regions.attr("opacity", statesBody.attr("opacity"));
statesBody.attr("opacity", null);
}
// v1.0 changed labels to multi-lined
labels.selectAll("textPath").each(function () {
const text = this.textContent;
const shift = this.getComputedTextLength() / -1.5;
this.innerHTML = /* html */ `<tspan x="${shift}">${text}</tspan>`;
});
// v1.0 added new biome - Wetland
biomesData.name.push("Wetland");
biomesData.color.push("#0b9131");
biomesData.habitability.push(12);
}
if (version < 1.1) {
// v1.0 initial code had a bug with religion layer id
if (!relig.size()) relig = viewbox.insert("g", "#terrain").attr("id", "relig");
// v1.0 initially has Sympathy status then relaced with Friendly
for (const s of pack.states) {
if (!s.diplomacy) continue;
s.diplomacy = s.diplomacy.map(r => (r === "Sympathy" ? "Friendly" : r));
}
// labels should be toggled via style attribute, so remove display attribute
labels.attr("display", null);
// v1.0 added religions heirarchy tree
if (pack.religions[1] && !pack.religions[1].code) {
pack.religions
.filter(r => r.i)
.forEach(r => {
r.origin = 0;
r.code = r.name.slice(0, 2);
});
}
if (!document.getElementById("freshwater")) {
lakes.append("g").attr("id", "freshwater");
lakes
.select("#freshwater")
.attr("opacity", 0.5)
.attr("fill", "#a6c1fd")
.attr("stroke", "#5f799d")
.attr("stroke-width", 0.7)
.attr("filter", null);
}
if (!document.getElementById("salt")) {
lakes.append("g").attr("id", "salt");
lakes
.select("#salt")
.attr("opacity", 0.5)
.attr("fill", "#409b8a")
.attr("stroke", "#388985")
.attr("stroke-width", 0.7)
.attr("filter", null);
}
// v1.1 added new lake and coast groups
if (!document.getElementById("sinkhole")) {
lakes.append("g").attr("id", "sinkhole");
lakes.append("g").attr("id", "frozen");
lakes.append("g").attr("id", "lava");
lakes
.select("#sinkhole")
.attr("opacity", 1)
.attr("fill", "#5bc9fd")
.attr("stroke", "#53a3b0")
.attr("stroke-width", 0.7)
.attr("filter", null);
lakes
.select("#frozen")
.attr("opacity", 0.95)
.attr("fill", "#cdd4e7")
.attr("stroke", "#cfe0eb")
.attr("stroke-width", 0)
.attr("filter", null);
lakes
.select("#lava")
.attr("opacity", 0.7)
.attr("fill", "#90270d")
.attr("stroke", "#f93e0c")
.attr("stroke-width", 2)
.attr("filter", "url(#crumpled)");
coastline.append("g").attr("id", "sea_island");
coastline.append("g").attr("id", "lake_island");
coastline
.select("#sea_island")
.attr("opacity", 0.5)
.attr("stroke", "#1f3846")
.attr("stroke-width", 0.7)
.attr("filter", "url(#dropShadow)");
coastline
.select("#lake_island")
.attr("opacity", 1)
.attr("stroke", "#7c8eaf")
.attr("stroke-width", 0.35)
.attr("filter", null);
}
// v1.1 features stores more data
defs.select("#land").selectAll("path").remove();
defs.select("#water").selectAll("path").remove();
coastline.selectAll("path").remove();
lakes.selectAll("path").remove();
drawCoastline();
}
if (version < 1.11) {
// v1.11 added new attributes
terrs.attr("scheme", "bright").attr("terracing", 0).attr("skip", 5).attr("relax", 0).attr("curve", 0);
svg.select("#oceanic > *").attr("id", "oceanicPattern");
oceanLayers.attr("layers", "-6,-3,-1");
gridOverlay.attr("type", "pointyHex").attr("size", 10);
// v1.11 added cultures heirarchy tree
if (pack.cultures[1] && !pack.cultures[1].code) {
pack.cultures
.filter(c => c.i)
.forEach(c => {
c.origin = 0;
c.code = c.name.slice(0, 2);
});
}
// v1.11 had an issue with fogging being displayed on load
unfog();
// v1.2 added new terrain attributes
if (!terrain.attr("set")) terrain.attr("set", "simple");
if (!terrain.attr("size")) terrain.attr("size", 1);
if (!terrain.attr("density")) terrain.attr("density", 0.4);
}
if (version < 1.21) {
// v1.11 replaced "display" attribute by "display" style
viewbox.selectAll("g").each(function () {
if (this.hasAttribute("display")) {
this.removeAttribute("display");
this.style.display = "none";
}
});
// v1.21 added rivers data to pack
pack.rivers = []; // rivers data
rivers.selectAll("path").each(function () {
const i = +this.id.slice(5);
const length = this.getTotalLength() / 2;
const s = this.getPointAtLength(length),
e = this.getPointAtLength(0);
const source = findCell(s.x, s.y),
mouth = findCell(e.x, e.y);
const name = Rivers.getName(mouth);
const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River";
pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type});
});
}
if (version < 1.22) {
// v1.22 changed state neighbors from Set object to array
BurgsAndStates.collectStatistics();
}
if (version < 1.3) {
// v1.3 added global options object
const winds = options.slice(); // previostly wind was saved in settings[19]
const year = rand(100, 2000);
const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era";
const eraShort = era[0] + "E";
const military = Military.getDefaultOptions();
options = {winds, year, era, eraShort, military};
// v1.3 added campaings data for all states
BurgsAndStates.generateCampaigns();
// v1.3 added militry layer
armies = viewbox.insert("g", "#icons").attr("id", "armies");
armies
.attr("opacity", 1)
.attr("fill-opacity", 1)
.attr("font-size", 6)
.attr("box-size", 3)
.attr("stroke", "#000")
.attr("stroke-width", 0.3);
turnButtonOn("toggleMilitary");
Military.generate();
}
if (version < 1.4) {
// v1.35 added dry lakes
if (!lakes.select("#dry").size()) {
lakes.append("g").attr("id", "dry");
lakes
.select("#dry")
.attr("opacity", 1)
.attr("fill", "#c9bfa7")
.attr("stroke", "#8e816f")
.attr("stroke-width", 0.7)
.attr("filter", null);
}
// v1.4 added ice layer
ice = viewbox.insert("g", "#coastline").attr("id", "ice").style("display", "none");
ice
.attr("opacity", null)
.attr("fill", "#e8f0f6")
.attr("stroke", "#e8f0f6")
.attr("stroke-width", 1)
.attr("filter", "url(#dropShadow05)");
drawIce();
// v1.4 added icon and power attributes for units
for (const unit of options.military) {
if (!unit.icon) unit.icon = getUnitIcon(unit.type);
if (!unit.power) unit.power = unit.crew;
}
function getUnitIcon(type) {
if (type === "naval") return "🌊";
if (type === "ranged") return "🏹";
if (type === "mounted") return "🐴";
if (type === "machinery") return "💣";
if (type === "armored") return "🐢";
if (type === "aviation") return "🦅";
if (type === "magical") return "🔮";
else return "⚔️";
}
// v1.4 added state reference for regiments
pack.states.filter(s => s.military).forEach(s => s.military.forEach(r => (r.state = s.i)));
}
if (version < 1.5) {
// not need to store default styles from v 1.5
localStorage.removeItem("styleClean");
localStorage.removeItem("styleGloom");
localStorage.removeItem("styleAncient");
localStorage.removeItem("styleMonochrome");
// v1.5 cultures has shield attribute
pack.cultures.forEach(culture => {
if (culture.removed) return;
culture.shield = Cultures.getRandomShield();
});
// v1.5 added burg type value
pack.burgs.forEach(burg => {
if (!burg.i || burg.removed) return;
burg.type = BurgsAndStates.getType(burg.cell, burg.port);
});
// v1.5 added emblems
defs.append("g").attr("id", "defs-emblems");
emblems = viewbox.insert("g", "#population").attr("id", "emblems").style("display", "none");
emblems.append("g").attr("id", "burgEmblems");
emblems.append("g").attr("id", "provinceEmblems");
emblems.append("g").attr("id", "stateEmblems");
regenerateEmblems();
toggleEmblems();
// v1.5 changed releif icons data
terrain.selectAll("use").each(function () {
const type = this.getAttribute("data-type") || this.getAttribute("xlink:href");
this.removeAttribute("xlink:href");
this.removeAttribute("data-type");
this.removeAttribute("data-size");
this.setAttribute("href", type);
});
}
if (version < 1.6) {
// v1.6 changed rivers data
for (const river of pack.rivers) {
const el = document.getElementById("river" + river.i);
if (el) {
river.widthFactor = +el.getAttribute("data-width");
el.removeAttribute("data-width");
el.removeAttribute("data-increment");
river.discharge = pack.cells.fl[river.mouth] || 1;
river.width = rn(river.length / 100, 2);
river.sourceWidth = 0.1;
} else {
Rivers.remove(river.i);
}
}
// v1.6 changed lakes data
for (const f of pack.features) {
if (f.type !== "lake") continue;
if (f.evaporation) continue;
f.flux = f.flux || f.cells * 3;
f.temp = grid.cells.temp[pack.cells.g[f.firstCell]];
f.height = f.height || d3.min(pack.cells.c[f.firstCell].map(c => pack.cells.h[c]).filter(h => h >= 20));
const height = (f.height - 18) ** heightExponentInput.value;
const evaporation = ((700 * (f.temp + 0.006 * height)) / 50 + 75) / (80 - f.temp);
f.evaporation = rn(evaporation * f.cells);
f.name = f.name || Lakes.getName(f);
delete f.river;
}
}
if (version < 1.61) {
// v1.61 changed rulers data
ruler.style("display", null);
rulers = new Rulers();
ruler.selectAll(".ruler > .white").each(function () {
const x1 = +this.getAttribute("x1");
const y1 = +this.getAttribute("y1");
const x2 = +this.getAttribute("x2");
const y2 = +this.getAttribute("y2");
if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return;
const points = [
[x1, y1],
[x2, y2]
];
rulers.create(Ruler, points);
});
ruler.selectAll("g.opisometer").each(function () {
const pointsString = this.dataset.points;
if (!pointsString) return;
const points = JSON.parse(pointsString);
rulers.create(Opisometer, points);
});
ruler.selectAll("path.planimeter").each(function () {
const length = this.getTotalLength();
if (length < 30) return;
const step = length > 1000 ? 40 : length > 400 ? 20 : 10;
const increment = length / Math.ceil(length / step);
const points = [];
for (let i = 0; i <= length; i += increment) {
const point = this.getPointAtLength(i);
points.push([point.x | 0, point.y | 0]);
}
rulers.create(Planimeter, points);
});
ruler.selectAll("*").remove();
if (rulers.data.length) {
turnButtonOn("toggleRulers");
rulers.draw();
} else turnButtonOff("toggleRulers");
// 1.61 changed oceanicPattern from rect to image
const pattern = document.getElementById("oceanic");
const filter = pattern.firstElementChild.getAttribute("filter");
const href = filter ? "./images/" + filter.replace("url(#", "").replace(")", "") + ".png" : "";
pattern.innerHTML = /* html */ `<image id="oceanicPattern" href=${href} width="100" height="100" opacity="0.2"></image>`;
}
if (version < 1.62) {
// v1.62 changed grid data
gridOverlay.attr("size", null);
}
if (version < 1.63) {
// v1.63 changed ocean pattern opacity element
const oceanPattern = document.getElementById("oceanPattern");
if (oceanPattern) oceanPattern.removeAttribute("opacity");
const oceanicPattern = document.getElementById("oceanicPattern");
if (!oceanicPattern.getAttribute("opacity")) oceanicPattern.setAttribute("opacity", 0.2);
// v 1.63 moved label text-shadow from css to editable inline style
burgLabels.select("#cities").style("text-shadow", "white 0 0 4px");
burgLabels.select("#towns").style("text-shadow", "white 0 0 4px");
labels.select("#states").style("text-shadow", "white 0 0 4px");
labels.select("#addedLabels").style("text-shadow", "white 0 0 4px");
}
if (version < 1.64) {
// v1.64 change states style
const opacity = regions.attr("opacity");
const filter = regions.attr("filter");
statesBody.attr("opacity", opacity).attr("filter", filter);
statesHalo.attr("opacity", opacity).attr("filter", "blur(5px)");
regions.attr("opacity", null).attr("filter", null);
}
if (version < 1.65) {
// v1.65 changed rivers data
d3.select("#rivers").attr("style", null); // remove style to unhide layer
const {cells, rivers} = pack;
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
for (const river of rivers) {
const node = document.getElementById("river" + river.i);
if (node && !river.cells) {
const riverCells = [];
const riverPoints = [];
const length = node.getTotalLength() / 2;
if (!length) continue;
const segments = Math.ceil(length / 6);
const increment = length / segments;
for (let i = 0; i <= segments; i++) {
const shift = increment * i;
const {x: x1, y: y1} = node.getPointAtLength(length + shift);
const {x: x2, y: y2} = node.getPointAtLength(length - shift);
const x = rn((x1 + x2) / 2, 1);
const y = rn((y1 + y2) / 2, 1);
const cell = findCell(x, y);
riverPoints.push([x, y]);
riverCells.push(cell);
}
river.cells = riverCells;
river.points = riverPoints;
}
river.widthFactor = defaultWidthFactor;
cells.i.forEach(i => {
const riverInWater = cells.r[i] && cells.h[i] < 20;
if (riverInWater) cells.r[i] = 0;
});
}
}
if (version < 1.652) {
// remove style to unhide layers
rivers.attr("style", null);
borders.attr("style", null);
}
if (version < 1.7) {
// v1.7 changed markers data
const defs = document.getElementById("defs-markers");
const markersGroup = document.getElementById("markers");
if (defs && markersGroup) {
const markerElements = markersGroup.querySelectorAll("use");
const rescale = +markersGroup.getAttribute("rescale");
pack.markers = Array.from(markerElements).map((el, i) => {
const id = el.getAttribute("id");
const note = notes.find(note => note.id === id);
if (note) note.id = `marker${i}`;
let x = +el.dataset.x;
let y = +el.dataset.y;
const transform = el.getAttribute("transform");
if (transform) {
const [dx, dy] = parseTransform(transform);
if (dx) x += +dx;
if (dy) y += +dy;
}
const cell = findCell(x, y);
const size = rn(rescale ? el.dataset.size * 30 : el.getAttribute("width"), 1);
const href = el.href.baseVal;
const type = href.replace("#marker_", "");
const symbol = defs?.querySelector(`symbol${href}`);
const text = symbol?.querySelector("text");
const circle = symbol?.querySelector("circle");
const icon = text?.innerHTML;
const px = text && Number(text.getAttribute("font-size")?.replace("px", ""));
const dx = text && Number(text.getAttribute("x")?.replace("%", ""));
const dy = text && Number(text.getAttribute("y")?.replace("%", ""));
const fill = circle && circle.getAttribute("fill");
const stroke = circle && circle.getAttribute("stroke");
const marker = {i, icon, type, x, y, size, cell};
if (size && size !== 30) marker.size = size;
if (!isNaN(px) && px !== 12) marker.px = px;
if (!isNaN(dx) && dx !== 50) marker.dx = dx;
if (!isNaN(dy) && dy !== 50) marker.dy = dy;
if (fill && fill !== "#ffffff") marker.fill = fill;
if (stroke && stroke !== "#000000") marker.stroke = stroke;
if (circle?.getAttribute("opacity") === "0") marker.pin = "no";
return marker;
});
markersGroup.style.display = null;
defs?.remove();
markerElements.forEach(el => el.remove());
if (layerIsOn("markers")) drawMarkers();
}
}
if (version < 1.72) {
// v1.72 renamed custom style presets
const storedStyles = Object.keys(localStorage).filter(key => key.startsWith("style"));
storedStyles.forEach(styleName => {
const style = localStorage.getItem(styleName);
const newStyleName = styleName.replace(/^style/, customPresetPrefix);
localStorage.setItem(newStyleName, style);
localStorage.removeItem(styleName);
});
}
if (version < 1.73) {
// v1.73 moved the hatching patterns out of the user's SVG
document.getElementById("hatching")?.remove();
// v1.73 added zone type to UI, ensure type is populated
const zones = Array.from(document.querySelectorAll("#zones > g"));
zones.forEach(zone => {
if (!zone.dataset.type) zone.dataset.type = "Unknown";
});
}
if (version < 1.84) {
// v1.84.0 added grid.cellsDesired to stored data
if (!grid.cellsDesired) grid.cellsDesired = rn((graphWidth * graphHeight) / grid.spacing ** 2, -3);
}
if (version < 1.85) {
// v1.84.0 moved intial screen out of maon svg
svg.select("#initial").remove();
}
if (version < 1.86) {
// v1.86.0 added multi-origin culture and religion hierarchy trees
for (const culture of pack.cultures) {
culture.origins = [culture.origin];
delete culture.origin;
}
for (const religion of pack.religions) {
religion.origins = [religion.origin];
delete religion.origin;
}
}
}

View file

@ -0,0 +1,919 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "/src/utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {byId} from "/src/utils/shorthands";
import {rn} from "/src/utils/numberUtils";
import {capitalize} from "/src/utils/stringUtils";
import {si} from "/src/utils/unitUtils";
import {abbreviate} from "/src/utils/languageUtils";
import {debounce} from "/src/utils/functionUtils";
const $body = insertEditorHtml();
addListeners();
const cultureTypes = ["Generic", "River", "Lake", "Naval", "Nomadic", "Hunting", "Highland"];
export function open() {
closeDialogs("#culturesEditor, .stable");
if (!layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleReligions")) toggleReligions();
if (layerIsOn("toggleProvinces")) toggleProvinces();
refreshCulturesEditor();
$("#culturesEditor").dialog({
title: "Cultures Editor",
resizable: false,
close: closeCulturesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
$body.focus();
}
function insertEditorHtml() {
const editorHtml = /* html */ `<div id="culturesEditor" class="dialog stable">
<div id="culturesHeader" class="header" style="grid-template-columns: 10em 7em 8em 4em 8em 5em 8em 8em">
<div data-tip="Click to sort by culture name" class="sortable alphabetically" data-sortby="name">Culture&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?v=1.87.01");
const getDescription = culture => {
const {name, type, rural, urban} = culture;
const population = rural * populationRate + urban * populationRate * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name} culture. ${type}. ${populationText}`;
};
const getShape = ({type}) => {
if (type === "Generic") return "circle";
if (type === "River") return "diamond";
if (type === "Lake") return "hexagon";
if (type === "Naval") return "square";
if (type === "Highland") return "concave";
if (type === "Nomadic") return "octagon";
if (type === "Hunting") return "pentagon";
};
HeirarchyTree.open({
type: "cultures",
data: pack.cultures,
onNodeEnter: cultureHighlightOn,
onNodeLeave: cultureHighlightOff,
getDescription,
getShape
});
}
function recalculateCultures(must) {
if (!must && !culturesAutoChange.checked) return;
pack.cells.culture = new Uint16Array(pack.cells.i.length);
pack.cultures.forEach(function (c) {
if (!c.i || c.removed) return;
pack.cells.culture[c.center] = c.i;
});
Cultures.expand();
drawCultures();
pack.burgs.forEach(b => (b.culture = pack.cells.culture[b.cell]));
refreshCulturesEditor();
document.querySelector("input.cultureExpan").focus(); // to not trigger hotkeys
}
function enterCultureManualAssignent() {
if (!layerIsOn("toggleCultures")) toggleCultures();
customization = 4;
cults.append("g").attr("id", "temp");
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "none"));
byId("culturesManuallyButtons").style.display = "inline-block";
debug.select("#cultureCenters").style("display", "none");
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
culturesFooter.style.display = "none";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
$("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
tip("Click on culture to select, drag the circle to change culture", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectCultureOnMapClick)
.call(d3.drag().on("start", dragCultureBrush))
.on("touchmove mousemove", moveCultureBrush);
$body.querySelector("div").classList.add("selected");
}
function selectCultureOnLineClick(i) {
if (customization !== 4) return;
$body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectCultureOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = cults.select("#temp").select("polygon[data-cell='" + i + "']");
const culture = assigned.size() ? +assigned.attr("data-culture") : pack.cells.culture[i];
$body.querySelector("div.selected").classList.remove("selected");
$body.querySelector("div[data-id='" + culture + "']").classList.add("selected");
}
function dragCultureBrush() {
const radius = +culturesManuallyBrush.value;
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const p = d3.mouse(this);
moveCircle(p[0], p[1], radius);
const found = radius > 5 ? findAll(p[0], p[1], radius) : [findCell(p[0], p[1], radius)];
const selection = found.filter(isLand);
if (selection) changeCultureForSelection(selection);
});
}
function changeCultureForSelection(selection) {
const temp = cults.select("#temp");
const selected = $body.querySelector("div.selected");
const cultureNew = +selected.dataset.id;
const color = pack.cultures[cultureNew].color || "#ffffff";
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const cultureOld = exists.size() ? +exists.attr("data-culture") : pack.cells.culture[i];
if (cultureNew === cultureOld) return;
// change of append new element
if (exists.size()) exists.attr("data-culture", cultureNew).attr("fill", color).attr("stroke", color);
else
temp
.append("polygon")
.attr("data-cell", i)
.attr("data-culture", cultureNew)
.attr("points", getPackPolygon(i))
.attr("fill", color)
.attr("stroke", color);
});
}
function moveCultureBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +culturesManuallyBrush.value;
moveCircle(point[0], point[1], radius);
}
function applyCultureManualAssignent() {
const changed = cults.select("#temp").selectAll("polygon");
changed.each(function () {
const i = +this.dataset.cell;
const c = +this.dataset.culture;
pack.cells.culture[i] = c;
if (pack.cells.burg[i]) pack.burgs[pack.cells.burg[i]].culture = c;
});
if (changed.size()) {
drawCultures();
refreshCulturesEditor();
}
exitCulturesManualAssignment();
}
function exitCulturesManualAssignment(close) {
customization = 0;
cults.select("#temp").remove();
removeCircle();
document.querySelectorAll("#culturesBottom > *").forEach(el => (el.style.display = "inline-block"));
byId("culturesManuallyButtons").style.display = "none";
culturesEditor.querySelectorAll(".hide").forEach(el => el.classList.remove("hidden"));
culturesFooter.style.display = "block";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close) $("#culturesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
debug.select("#cultureCenters").style("display", null);
restoreDefaultEvents();
clearMainTip();
const selected = $body.querySelector("div.selected");
if (selected) selected.classList.remove("selected");
}
function enterAddCulturesMode() {
if (this.classList.contains("pressed")) return exitAddCultureMode();
customization = 9;
this.classList.add("pressed");
tip("Click on the map to add a new culture", true);
viewbox.style("cursor", "crosshair").on("click", addCulture);
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
}
function exitAddCultureMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (culturesAdd.classList.contains("pressed")) culturesAdd.classList.remove("pressed");
}
function addCulture() {
const point = d3.mouse(this);
const center = findCell(point[0], point[1]);
if (pack.cells.h[center] < 20)
return tip("You cannot place culture center into the water. Please click on a land cell", false, "error");
const occupied = pack.cultures.some(c => !c.removed && c.center === center);
if (occupied) return tip("This cell is already a culture center. Please select a different cell", false, "error");
if (d3.event.shiftKey === false) exitAddCultureMode();
Cultures.add(center);
drawCultureCenters();
culturesEditorAddLines();
}
function downloadCulturesCsv() {
const unit = getAreaUnit("2");
const headers = `Id,Name,Color,Cells,Expansionism,Type,Area ${unit},Population,Namesbase,Emblems Shape,Origins`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, color, cells, expansionism, type, area, population, emblems, base} = $line.dataset;
const namesbase = nameBases[+base].name;
const {origins} = pack.cultures[+id];
const originList = origins.filter(origin => origin).map(origin => pack.cultures[origin].name);
const originText = '"' + originList.join(", ") + '"';
return [id, name, color, cells, expansionism, type, area, population, namesbase, emblems, originText].join(",");
});
const csvData = [headers].concat(data).join("\n");
const name = getFileName("Cultures") + ".csv";
downloadFile(csvData, name);
}
function closeCulturesEditor() {
debug.select("#cultureCenters").remove();
exitCulturesManualAssignment("close");
exitAddCultureMode();
}
async function uploadCulturesData() {
const csv = await Formats.csvParser(this.files[0]);
this.value = "";
const {cultures, cells} = pack;
const shapes = Object.keys(COA.shields.types)
.map(type => Object.keys(COA.shields[type]))
.flat();
const populated = cells.pop.map((c, i) => (c ? i : null)).filter(c => c);
cultures.forEach(item => {
if (item.i) item.removed = true;
});
for (const c of csv.iterator((a, b) => +a[0] > +b[0])) {
let current;
if (+c.id < cultures.length) {
current = cultures[c.id];
const ratio = current.urban / (current.rural + current.urban);
applyPopulationChange(current.rural, current.urban, c.population * (1 - ratio), c.population * ratio, +c.id);
} else {
current = {i: cultures.length, center: ra(populated), area: 0, cells: 0, origin: 0, rural: 0, urban: 0};
cultures.push(current);
}
current.removed = false;
current.name = c.culture;
current.code = abbreviate(
current.name,
cultures.map(c => c.code)
);
current.color = c.color;
current.expansionism = +c.expansionism;
current.origins = JSON.parse(c.origins);
if (cultureTypes.includes(c.type)) current.type = c.type;
else current.type = "Generic";
const shieldShape = c["emblems shape"].toLowerCase();
if (shapes.includes(shieldShape)) current.shield = shieldShape;
else current.shield = "heater";
const nameBaseIndex = nameBases.findIndex(n => n.name == c.namesbase);
current.base = nameBaseIndex === -1 ? 0 : nameBaseIndex;
}
cultures.filter(c => c.removed).forEach(c => removeCulture(c.i));
drawCultures();
refreshCulturesEditor();
}

View file

@ -0,0 +1,766 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "/src/utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {byId} from "/src/utils/shorthands";
import {rn} from "/src/utils/numberUtils";
import {si} from "/src/utils/unitUtils";
import {abbreviate} from "/src/utils/languageUtils";
import {debounce} from "/src/utils/functionUtils";
const $body = insertEditorHtml();
addListeners();
export function open() {
closeDialogs("#religionsEditor, .stable");
if (!layerIsOn("toggleReligions")) toggleCultures();
if (layerIsOn("toggleStates")) toggleStates();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleCultures")) toggleReligions();
if (layerIsOn("toggleProvinces")) toggleProvinces();
refreshReligionsEditor();
drawReligionCenters();
$("#religionsEditor").dialog({
title: "Religions Editor",
resizable: false,
close: closeReligionsEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
$body.focus();
}
function insertEditorHtml() {
const editorHtml = /* html */ `<div id="religionsEditor" class="dialog stable">
<div id="religionsHeader" class="header" style="grid-template-columns: 13em 6em 7em 18em 5em 6em">
<div data-tip="Click to sort by religion name" class="sortable alphabetically" data-sortby="name">Religion&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?v=1.87.01");
const getDescription = religion => {
const {name, type, form, rural, urban} = religion;
const getTypeText = () => {
if (name.includes(type)) return "";
if (form.includes(type)) return "";
if (type === "Folk" || type === "Organized") return `. ${type} religion`;
return `. ${type}`;
};
const formText = form === type ? "" : ". " + form;
const population = rural * populationRate + urban * populationRate * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name}${getTypeText()}${formText}. ${populationText}`;
};
const getShape = ({type}) => {
if (type === "Folk") return "circle";
if (type === "Organized") return "square";
if (type === "Cult") return "hexagon";
if (type === "Heresy") return "diamond";
};
HeirarchyTree.open({
type: "religions",
data: pack.religions,
onNodeEnter: religionHighlightOn,
onNodeLeave: religionHighlightOff,
getDescription,
getShape
});
}
function toggleExtinct() {
$body.dataset.extinct = $body.dataset.extinct !== "show" ? "show" : "hide";
religionsEditorAddLines();
}
function enterReligionsManualAssignent() {
if (!layerIsOn("toggleReligions")) toggleReligions();
customization = 7;
relig.append("g").attr("id", "temp");
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "none"));
byId("religionsManuallyButtons").style.display = "inline-block";
debug.select("#religionCenters").style("display", "none");
religionsEditor.querySelectorAll(".hide").forEach(el => el.classList.add("hidden"));
religionsFooter.style.display = "none";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
$("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
tip("Click on religion to select, drag the circle to change religion", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectReligionOnMapClick)
.call(d3.drag().on("start", dragReligionBrush))
.on("touchmove mousemove", moveReligionBrush);
$body.querySelector("div").classList.add("selected");
}
function selectReligionOnLineClick(i) {
if (customization !== 7) return;
$body.querySelector("div.selected").classList.remove("selected");
this.classList.add("selected");
}
function selectReligionOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
if (pack.cells.h[i] < 20) return;
const assigned = relig.select("#temp").select("polygon[data-cell='" + i + "']");
const religion = assigned.size() ? +assigned.attr("data-religion") : pack.cells.religion[i];
$body.querySelector("div.selected").classList.remove("selected");
$body.querySelector("div[data-id='" + religion + "']").classList.add("selected");
}
function dragReligionBrush() {
const radius = +byId("religionsManuallyBrushNumber").value;
d3.event.on("drag", () => {
if (!d3.event.dx && !d3.event.dy) return;
const [x, y] = d3.mouse(this);
moveCircle(x, y, radius);
const found = radius > 5 ? findAll(x, y, radius) : [findCell(x, y, radius)];
const selection = found.filter(isLand);
if (selection) changeReligionForSelection(selection);
});
}
// change religion within selection
function changeReligionForSelection(selection) {
const temp = relig.select("#temp");
const selected = $body.querySelector("div.selected");
const religionNew = +selected.dataset.id;
const color = pack.religions[religionNew].color || "#ffffff";
selection.forEach(function (i) {
const exists = temp.select("polygon[data-cell='" + i + "']");
const religionOld = exists.size() ? +exists.attr("data-religion") : pack.cells.religion[i];
if (religionNew === religionOld) return;
// change of append new element
if (exists.size()) exists.attr("data-religion", religionNew).attr("fill", color);
else
temp
.append("polygon")
.attr("data-cell", i)
.attr("data-religion", religionNew)
.attr("points", getPackPolygon(i))
.attr("fill", color);
});
}
function moveReligionBrush() {
showMainTip();
const [x, y] = d3.mouse(this);
const radius = +byId("religionsManuallyBrushNumber").value;
moveCircle(x, y, radius);
}
function applyReligionsManualAssignent() {
const changed = relig.select("#temp").selectAll("polygon");
changed.each(function () {
const i = +this.dataset.cell;
const r = +this.dataset.religion;
pack.cells.religion[i] = r;
});
if (changed.size()) {
drawReligions();
refreshReligionsEditor();
drawReligionCenters();
}
exitReligionsManualAssignment();
}
function exitReligionsManualAssignment(close) {
customization = 0;
relig.select("#temp").remove();
removeCircle();
document.querySelectorAll("#religionsBottom > button").forEach(el => (el.style.display = "inline-block"));
byId("religionsManuallyButtons").style.display = "none";
byId("religionsEditor")
.querySelectorAll(".hide")
.forEach(el => el.classList.remove("hidden"));
byId("religionsFooter").style.display = "block";
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close) $("#religionsEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg"}});
debug.select("#religionCenters").style("display", null);
restoreDefaultEvents();
clearMainTip();
const $selected = $body.querySelector("div.selected");
if ($selected) $selected.classList.remove("selected");
}
function enterAddReligionMode() {
if (this.classList.contains("pressed")) return exitAddReligionMode();
customization = 8;
this.classList.add("pressed");
tip("Click on the map to add a new religion", true);
viewbox.style("cursor", "crosshair").on("click", addReligion);
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "none"));
}
function exitAddReligionMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
$body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (religionsAdd.classList.contains("pressed")) religionsAdd.classList.remove("pressed");
}
function addReligion() {
const [x, y] = d3.mouse(this);
const center = findCell(x, y);
if (pack.cells.h[center] < 20)
return tip("You cannot place religion center into the water. Please click on a land cell", false, "error");
const occupied = pack.religions.some(r => !r.removed && r.center === center);
if (occupied) return tip("This cell is already a religion center. Please select a different cell", false, "error");
if (d3.event.shiftKey === false) exitAddReligionMode();
Religions.add(center);
drawReligions();
refreshReligionsEditor();
drawReligionCenters();
}
function downloadReligionsCsv() {
const unit = getAreaUnit("2");
const headers = `Id,Name,Color,Type,Form,Supreme Deity,Area ${unit},Believers,Origins`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, color, type, form, deity, area, population} = $line.dataset;
const deityText = '"' + deity + '"';
const {origins} = pack.religions[+id];
const originList = (origins || []).filter(origin => origin).map(origin => pack.religions[origin].name);
const originText = '"' + originList.join(", ") + '"';
return [id, name, color, type, form, deityText, area, population, originText].join(",");
});
const csvData = [headers].concat(data).join("\n");
const name = getFileName("Religions") + ".csv";
downloadFile(csvData, name);
}
function closeReligionsEditor() {
debug.select("#religionCenters").remove();
exitReligionsManualAssignment("close");
exitAddReligionMode();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
import {tip} from "/src/scripts/tooltips";
export function exportToJson(type) {
if (customization)
return tip("Data cannot be exported when edit mode is active, please exit the mode and retry", false, "error");
closeDialogs("#alert");
const typeMap = {
Full: getFullDataJson,
Minimal: getMinimalDataJson,
PackCells: getPackCellsDataJson,
GridCells: getGridCellsDataJson
};
const mapData = typeMap[type]();
const blob = new Blob([mapData], {type: "application/json"});
const URL = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = getFileName(type) + ".json";
link.href = URL;
link.click();
tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
window.URL.revokeObjectURL(URL);
}
function getFullDataJson() {
TIME && console.time("getFullDataJson");
const info = getMapInfo();
const settings = getSettings();
const cells = getPackCellsData();
const vertices = getPackVerticesData();
const exportData = {info, settings, coords: mapCoordinates, cells, vertices, biomes: biomesData, notes, nameBases};
TIME && console.timeEnd("getFullDataJson");
return JSON.stringify(exportData);
}
function getMinimalDataJson() {
TIME && console.time("getMinimalDataJson");
const info = getMapInfo();
const settings = getSettings();
const packData = {
features: pack.features,
cultures: pack.cultures,
burgs: pack.burgs,
states: pack.states,
provinces: pack.provinces,
religions: pack.religions,
rivers: pack.rivers,
markers: pack.markers
};
const exportData = {info, settings, coords: mapCoordinates, pack: packData, biomes: biomesData, notes, nameBases};
TIME && console.timeEnd("getMinimalDataJson");
return JSON.stringify(exportData);
}
function getPackCellsDataJson() {
TIME && console.time("getCellsDataJson");
const info = getMapInfo();
const cells = getPackCellsData();
const exportData = {info, cells};
TIME && console.timeEnd("getCellsDataJson");
return JSON.stringify(exportData);
}
function getGridCellsDataJson() {
TIME && console.time("getGridCellsDataJson");
const info = getMapInfo();
const gridCells = getGridCellsData();
const exportData = {info, gridCells};
TIME && console.log("getGridCellsDataJson");
return JSON.stringify(exportData);
}
function getMapInfo() {
const info = {
version,
description: "Azgaar's Fantasy Map Generator output: azgaar.github.io/Fantasy-map-generator",
exportedAt: new Date().toISOString(),
mapName: mapName.value,
seed,
mapId
};
return info;
}
function getSettings() {
const settings = {
distanceUnit: distanceUnitInput.value,
distanceScale: distanceScaleInput.value,
areaUnit: areaUnit.value,
heightUnit: heightUnit.value,
heightExponent: heightExponentInput.value,
temperatureScale: temperatureScale.value,
barSize: barSizeInput.value,
barLabel: barLabel.value,
barBackOpacity: barBackOpacity.value,
barBackColor: barBackColor.value,
barPosX: barPosX.value,
barPosY: barPosY.value,
populationRate: populationRate,
urbanization: urbanization,
mapSize: mapSizeOutput.value,
latitudeO: latitudeOutput.value,
temperatureEquator: temperatureEquatorOutput.value,
temperaturePole: temperaturePoleOutput.value,
prec: precOutput.value,
options: options,
mapName: mapName.value,
hideLabels: hideLabels.checked,
stylePreset: stylePreset.value,
rescaleLabels: rescaleLabels.checked,
urbanDensity: urbanDensity
};
return settings;
}
function getPackCellsData() {
const cellConverted = {
i: Array.from(pack.cells.i),
v: pack.cells.v,
c: pack.cells.c,
p: pack.cells.p,
g: Array.from(pack.cells.g),
h: Array.from(pack.cells.h),
area: Array.from(pack.cells.area),
f: Array.from(pack.cells.f),
t: Array.from(pack.cells.t),
haven: Array.from(pack.cells.haven),
harbor: Array.from(pack.cells.harbor),
fl: Array.from(pack.cells.fl),
r: Array.from(pack.cells.r),
conf: Array.from(pack.cells.conf),
biome: Array.from(pack.cells.biome),
s: Array.from(pack.cells.s),
pop: Array.from(pack.cells.pop),
culture: Array.from(pack.cells.culture),
burg: Array.from(pack.cells.burg),
road: Array.from(pack.cells.road),
crossroad: Array.from(pack.cells.crossroad),
state: Array.from(pack.cells.state),
religion: Array.from(pack.cells.religion),
province: Array.from(pack.cells.province)
};
const cellObjArr = [];
{
cellConverted.i.forEach(value => {
const cellobj = {
i: value,
v: cellConverted.v[value],
c: cellConverted.c[value],
p: cellConverted.p[value],
g: cellConverted.g[value],
h: cellConverted.h[value],
area: cellConverted.area[value],
f: cellConverted.f[value],
t: cellConverted.t[value],
haven: cellConverted.haven[value],
harbor: cellConverted.harbor[value],
fl: cellConverted.fl[value],
r: cellConverted.r[value],
conf: cellConverted.conf[value],
biome: cellConverted.biome[value],
s: cellConverted.s[value],
pop: cellConverted.pop[value],
culture: cellConverted.culture[value],
burg: cellConverted.burg[value],
road: cellConverted.road[value],
crossroad: cellConverted.crossroad[value],
state: cellConverted.state[value],
religion: cellConverted.religion[value],
province: cellConverted.province[value]
};
cellObjArr.push(cellobj);
});
}
const cellsData = {
cells: cellObjArr,
features: pack.features,
cultures: pack.cultures,
burgs: pack.burgs,
states: pack.states,
provinces: pack.provinces,
religions: pack.religions,
rivers: pack.rivers,
markers: pack.markers
};
return cellsData;
}
function getGridCellsData() {
const gridData = {
cellsDesired: grid.cellsDesired,
spacing: grid.spacing,
cellsY: grid.cellsY,
cellsX: grid.cellsX,
points: grid.points,
boundary: grid.boundary
};
return gridData;
}
function getPackVerticesData() {
const {vertices} = pack;
const verticesNumber = vertices.p.length;
const verticesArray = new Array(verticesNumber);
for (let i = 0; i < verticesNumber; i++) {
verticesArray[i] = {
p: vertices.p[i],
v: vertices.v[i],
c: vertices.c[i]
};
}
return verticesArray;
}

View file

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

View file

@ -0,0 +1,512 @@
import {byId} from "/src/utils/shorthands";
import {tip} from "/src/scripts/tooltips";
import {capitalize} from "/src/utils/stringUtils";
appendStyleSheet();
insertHtml();
const MARGINS = {top: 10, right: 10, bottom: -5, left: 10};
const handleZoom = () => viewbox.attr("transform", d3.event.transform);
const zoom = d3.zoom().scaleExtent([0.2, 1.5]).on("zoom", handleZoom);
// store old root for transitions
let oldRoot;
// define svg elements
const svg = d3.select("#hierarchyTree > svg").call(zoom);
const viewbox = svg.select("g#hierarchyTree_viewbox");
const primaryLinks = viewbox.select("g#hierarchyTree_linksPrimary");
const secondaryLinks = viewbox.select("g#hierarchyTree_linksSecondary");
const nodes = viewbox.select("g#hierarchyTree_nodes");
const dragLine = viewbox.select("path#hierarchyTree_dragLine");
// properties
let dataElements; // {i, name, type, origins}[], e.g. path.religions
let validElements; // not-removed dataElements
let onNodeEnter; // d3Data => void
let onNodeLeave; // d3Data => void
let getDescription; // dataElement => string
let getShape; // dataElement => string;
export function open(props) {
closeDialogs("#hierarchyTree, .stable");
dataElements = props.data;
dataElements[0].origins = [null];
validElements = dataElements.filter(r => !r.removed);
if (validElements.length < 3) return tip(`Not enough ${props.type} to show hierarchy`, false, "error");
onNodeEnter = props.onNodeEnter;
onNodeLeave = props.onNodeLeave;
getDescription = props.getDescription;
getShape = props.getShape;
const root = getRoot();
const treeWidth = root.leaves().length * 50;
const treeHeight = root.height * 50;
const w = treeWidth - MARGINS.left - MARGINS.right;
const h = treeHeight + 30 - MARGINS.top - MARGINS.bottom;
const treeLayout = d3.tree().size([w, h]);
const width = minmax(treeWidth, 300, innerWidth * 0.75);
const height = minmax(treeHeight, 200, innerHeight * 0.75);
zoom.extent([Array(2).fill(0), [width, height]]);
svg.attr("viewBox", `0, 0, ${width}, ${height}`);
$("#hierarchyTree").dialog({
title: `${capitalize(props.type)} tree`,
position: {my: "left center", at: "left+10 center", of: "svg"},
width
});
renderTree(root, treeLayout);
}
function appendStyleSheet() {
const style = document.createElement("style");
style.textContent = /* css */ `
#hierarchyTree_selectedOrigins > button {
margin: 0 2px;
}
#hierarchyTree {
display: flex;
flex-direction: column;
justify-content: space-between;
}
#hierarchyTree > svg {
height: 100%;
}
.hierarchyTree_selectedOrigins {
margin-right: 15px;
}
.hierarchyTree_selectedOrigin {
border: 1px solid #aaa;
background: none;
padding: 1px 4px;
}
.hierarchyTree_selectedOrigin:hover {
border: 1px solid #333;
}
.hierarchyTree_selectedOrigin::after {
content: "✕";
margin-left: 8px;
color: #999;
}
.hierarchyTree_selectedOrigin:hover:after {
color: #333;
}
#hierarchyTree_originSelector {
display: none;
}
#hierarchyTree_originSelector > form > div {
padding: 0.3em;
margin: 1px 0;
border-radius: 1em;
}
#hierarchyTree_originSelector > form > div:hover {
background-color: #ddd;
}
#hierarchyTree_originSelector > form > div[checked] {
background-color: #c6d6d6;
}
#hierarchyTree_nodes > g > text {
pointer-events: none;
stroke: none;
font-size: 11px;
}
#hierarchyTree_nodes > g.selected {
stroke: #c13119;
stroke-width: 1;
cursor: move;
}
#hierarchyTree_dragLine {
marker-end: url(#end-arrow);
stroke: #333333;
stroke-dasharray: 5;
stroke-dashoffset: 1000;
animation: dash 80s linear backwards;
}
`;
document.head.appendChild(style);
}
function insertHtml() {
const html = /* html */ `<div id="hierarchyTree" class="dialog" style="overflow: hidden;">
<svg>
<g id="hierarchyTree_viewbox" style="text-anchor: middle; dominant-baseline: central">
<g transform="translate(10, -45)">
<g id="hierarchyTree_links" fill="none" stroke="#aaa">
<g id="hierarchyTree_linksPrimary"></g>
<g id="hierarchyTree_linksSecondary" stroke-dasharray="1"></g>
</g>
<g id="hierarchyTree_nodes"></g>
<path id="hierarchyTree_dragLine" path='' />
</g>
</g>
</svg>
<div id="hierarchyTree_details" class='chartInfo'>
<div id='hierarchyTree_infoLine' style="display: block">&#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

@ -0,0 +1,75 @@
import {tip} from "/src/scripts/tooltips";
// module to prompt PWA installation
let installButton = null;
let deferredPrompt = null;
export function init(event) {
const dontAskforInstallation = localStorage.getItem("installationDontAsk");
if (dontAskforInstallation) return;
installButton = createButton();
deferredPrompt = event;
window.addEventListener("appinstalled", () => {
tip("Application is installed", false, "success", 8000);
cleanup();
});
}
function createButton() {
const button = document.createElement("button");
button.style = `
position: fixed;
top: 1em;
right: 1em;
padding: 0.6em 0.8em;
width: auto;
`;
button.className = "options glow";
button.innerHTML = "Install";
button.onclick = openDialog;
button.onmouseenter = () => tip("Install the Application");
document.querySelector("body").appendChild(button);
return button;
}
function openDialog() {
alertMessage.innerHTML = /* html */ `You can install the tool so that it will look and feel like desktop application:
have its own icon on your home screen and work offline with some limitations
`;
$("#alert").dialog({
resizable: false,
title: "Install the Application",
width: "38em",
buttons: {
Install: function () {
$(this).dialog("close");
deferredPrompt.prompt();
},
Cancel: function () {
$(this).dialog("close");
}
},
open: function () {
const checkbox =
'<span><input id="dontAsk" class="checkbox" type="checkbox"><label for="dontAsk" class="checkbox-label dontAsk"><i>do not ask again</i></label><span>';
const pane = this.parentElement.querySelector(".ui-dialog-buttonpane");
pane.insertAdjacentHTML("afterbegin", checkbox);
},
close: function () {
const box = this.parentElement.querySelector(".checkbox");
if (box?.checked) {
localStorage.setItem("installationDontAsk", true);
cleanup();
}
$(this).dialog("destroy");
}
});
function cleanup() {
installButton.remove();
installButton = null;
deferredPrompt = null;
}
}

View file

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

View file

@ -0,0 +1,44 @@
import {capitalize} from "../../utils/stringUtils";
const format = rawList =>
rawList
.replace(/(?:\r\n|\r|\n)/g, "")
.split(",")
.map(name => capitalize(name.trim()))
.sort();
export const supporters = format(`
Aaron Meyer,Ahmad Amerih,AstralJacks,aymeric,Billy Dean Goehring,Branndon Edwards,Chase Mayers,Curt Flood,cyninge,Dino Princip,
E.M. White,es,Fondue,Fritjof Olsson,Gatsu,Johan Fröberg,Jonathan Moore,Joseph Miranda,Kate,KC138,Luke Nelson,Markus Finster,Massimo Vella,Mikey,
Nathan Mitchell,Paavi1,Pat,Ryan Westcott,Sasquatch,Shawn Spencer,Sizz_TV,Timothée CALLET,UTG community,Vlad Tomash,Wil Sisney,William Merriott,
Xariun,Gun Metal Games,Scott Marner,Spencer Sherman,Valerii Matskevych,Alloyed Clavicle,Stewart Walsh,Ruthlyn Mollett (Javan),Benjamin Mair-Pratt,
Diagonath,Alexander Thomas,Ashley Wilson-Savoury,William Henry,Preston Brooks,JOSHUA QUALTIERI,Hilton Williams,Katharina Haase,Hisham Bedri,
Ian arless,Karnat,Bird,Kevin,Jessica Thomas,Steve Hyatt,Logicspren,Alfred García,Jonathan Killstring,John Ackley,Invad3r233,Norbert Žigmund,Jennifer,
PoliticsBuff,_gfx_,Maggie,Connor McMartin,Jared McDaris,BlastWind,Franc Casanova Ferrer,Dead & Devil,Michael Carmody,Valerie Elise,naikibens220,
Jordon Phillips,William Pucs,The Dungeon Masters,Brady R Rathbun,J,Shadow,Matthew Tiffany,Huw Williams,Joseph Hamilton,FlippantFeline,Tamashi Toh,
kms,Stephen Herron,MidnightMoon,Whakomatic x,Barished,Aaron bateson,Brice Moss,Diklyquill,PatronUser,Michael Greiner,Steven Bennett,Jacob Harrington,
Miguel C.,Reya C.,Giant Monster Games,Noirbard,Brian Drennen,Ben Craigie,Alex Smolin,Endwords,Joshua E Goodwin,SirTobit ,Allen S. Rout,Allen Bull Bear,
Pippa Mitchell,R K,G0atfather,Ryan Lege,Caner Oleas Pekgönenç,Bradley Edwards,Tertiary ,Austin Miller,Jesse Holmes,Jan Dvořák,Marten F,Erin D. Smale,
Maxwell Hill,Drunken_Legends,rob bee,Jesse Holmes,YYako,Detocroix,Anoplexian,Hannah,Paul,Sandra Krohn,Lucid,Richard Keating,Allen Varney,Rick Falkvinge,
Seth Fusion,Adam Butler,Gus,StroboWolf,Sadie Blackthorne,Zewen Senpai,Dell McKnight,Oneiris,Darinius Dragonclaw Studios,Christopher Whitney,Rhodes HvZ,
Jeppe Skov Jensen,María Martín López,Martin Seeger,Annie Rishor,Aram Sabatés,MadNomadMedia,Eric Foley,Vito Martono,James H. Anthony,Kevin Cossutta,
Thirty-OneR,ThatGuyGW,Dee Chiu,MontyBoosh,Achillain,Jaden,SashaTK,Steve Johnson,Pierrick Bertrand,Jared Kennedy,Dylan Devenny,Kyle Robertson,
Andrew Rostaing,Daniel Gill,Char,Jack,Barna Csíkos,Ian Rousseau,Nicholas Grabstas,Tom Van Orden jr,Bryan Brake,Akylos,Riley Seaman,MaxOliver,Evan-DiLeo,
Alex Debus,Joshua Vaught,Kyle S,Eric Moore,Dean Dunakin,Uniquenameosaurus,WarWizardGames,Chance Mena,Jan Ka,Miguel Alejandro,Dalton Clark,Simon Drapeau,
Radovan Zapletal,Jmmat6,Justa Badge,Blargh Blarghmoomoo,Vanessa Anjos,Grant A. Murray,Akirsop,Rikard Wolff,Jake Fish,teco 47,Antiroo,Jakob Siegel,
Guilherme Aguiar,Jarno Hallikainen,Justin Mcclain,Kristin Chernoff,Rowland Kingman,Esther Busch,Grayson McClead,Austin,Hakon the Viking,Chad Riley,
Cooper Counts,Patrick Jones,Clonetone,PlayByMail.Net,Brad Wardell,Lance Saba,Egoensis,Brea Richards,Tiber,Chris Bloom,Maxim Lowe,Aquelion,
Page One Project,Spencer Morris,Paul Ingram,Dust Bunny,Adrian Wright,Eric Alexander Cartaya,GameNight,Thomas Mortensen Hansen,Zklaus,Drinarius,
Ed Wright,Lon Varnadore,Crys Cain,Heaven N Lee,Jeffrey Henning,Lazer Elf,Jordan Bellah,Alex Beard,Kass Frisson,Petro Lombaard,Emanuel Pietri,Rox,
PinkEvil,Gavin Madrigal,Martin Lorber,Prince of Morgoth,Jaryd Armstrong,Andrew Pirkola,ThyHolyDevil,Gary Smith,Tyshaun Wise,Ethan Cook,Jon Stroman,
Nobody679,良义 ,Chris Gray,Phoenix Boatwright,Mackenzie,Milo Cohen,Jason Matthew Wuerfel,Rasmus Legêne,Andrew Hines,Wexxler,Espen Sæverud,Binks,
Dominick Ormsby,Linn Browning,Václav Švec,Alan Buehne,George J.Lekkas,Alexandre Boivin,Tommy Mayfield,Skylar Mangum-Turner,Karen Blythe,Stefan Gugerel,
Mike Conley,Xavier privé,Hope You're Well,Mark Sprietsma,Robert Landry,Nick Mowry,steve hall,Markell,Josh Wren,Neutrix,BLRageQuit,Rocky,
Dario Spadavecchia,Bas Kroot,John Patrick Callahan Jr,Alexandra Vesey,D,Exp1nt,james,Braxton Istace,w,Rurikid,AntiBlock,Redsauz,BigE0021,
Jonathan Williams,ojacid .,Brian Wilson,A Patreon of the Ahts,Shubham Jakhotiya,www15o,Jan Bundesmann,Angelique Badger,Joshua Xiong,Moist mongol,
Frank Fewkes,jason baldrick,Game Master Pro,Andrew Kircher,Preston Mitchell,Chris Kohut,Emarandzeb,Trentin Bergeron,Damon Gallaty,Pleaseworkforonce,
Jordan,William Markus,Sidr Dim,Alexander Whittaker,The Next Level,Patrick Valverde,Markus Peham,Daniel Cooper,the Beagles of Neorbus,Marley Moule,
Maximilian Schielke,Johnathan Xavier Hutchinson,Ele,Rita,Randy Ross,John Wick,RedSpaz,cameron cannon,Ian Grau-Fay,Kyle Barrett,Charlotte Wiland,
David Kaul,E. Jason Davis,Cyberate,Atenfox,Sea Wolf,Holly Loveless,Roekai,Alden Z,angel carrillo,Sam Spoerle,S A Rudy,Bird Law Expert,Mira Cyr,
Aaron Blair,Neyimadd,RLKZ1022,DerWolf,Kenji Yamada,Zion,Robert Rinne,Actual_Dio,Kyarou
`);

377
src/modules/fonts.js Normal file
View file

@ -0,0 +1,377 @@
import {tip} from "/src/scripts/tooltips";
const fonts = [
{family: "Arial"},
{family: "Times New Roman"},
{family: "Georgia"},
{family: "Garamond"},
{family: "Lucida Sans Unicode"},
{family: "Courier New"},
{family: "Verdana"},
{family: "Impact"},
{family: "Comic Sans MS"},
{family: "Papyrus"},
{
family: "Almendra SC",
src: "url(https://fonts.gstatic.com/s/almendrasc/v13/Iure6Yx284eebowr7hbyTaZOrLQ.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Amarante",
src: "url(https://fonts.gstatic.com/s/amarante/v22/xMQXuF1KTa6EvGx9bp-wAXs.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Amatic SC",
src: "url(https://fonts.gstatic.com/s/amaticsc/v11/TUZ3zwprpvBS1izr_vOMscGKfrUC.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Arima Madurai",
src: "url(https://fonts.gstatic.com/s/arimamadurai/v14/t5tmIRoeKYORG0WNMgnC3seB3T7Prw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Architects Daughter",
src: "url(https://fonts.gstatic.com/s/architectsdaughter/v8/RXTgOOQ9AAtaVOHxx0IUBM3t7GjCYufj5TXV5VnA2p8.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Bitter",
src: "url(https://fonts.gstatic.com/s/bitter/v12/zfs6I-5mjWQ3nxqccMoL2A.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Caesar Dressing",
src: "url(https://fonts.gstatic.com/s/caesardressing/v6/yYLx0hLa3vawqtwdswbotmK4vrRHdrz7.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Cinzel",
src: "url(https://fonts.gstatic.com/s/cinzel/v7/zOdksD_UUTk1LJF9z4tURA.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Dancing Script",
src: "url(https://fonts.gstatic.com/s/dancingscript/v9/KGBfwabt0ZRLA5W1ywjowUHdOuSHeh0r6jGTOGdAKHA.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Faster One",
src: "url(https://fonts.gstatic.com/s/fasterone/v17/H4ciBXCHmdfClFb-vWhf-LyYhw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Forum",
src: "url(https://fonts.gstatic.com/s/forum/v16/6aey4Ky-Vb8Ew8IROpI.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Fredericka the Great",
src: "url(https://fonts.gstatic.com/s/frederickathegreat/v6/9Bt33CxNwt7aOctW2xjbCstzwVKsIBVV--Sjxbc.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Gloria Hallelujah",
src: "url(https://fonts.gstatic.com/s/gloriahallelujah/v9/CA1k7SlXcY5kvI81M_R28cNDay8z-hHR7F16xrcXsJw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Great Vibes",
src: "url(https://fonts.gstatic.com/s/greatvibes/v5/6q1c0ofG6NKsEhAc2eh-3Y4P5ICox8Kq3LLUNMylGO4.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Henny Penny",
src: "url(https://fonts.gstatic.com/s/hennypenny/v17/wXKvE3UZookzsxz_kjGSfPQtvXI.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "IM Fell English",
src: "url(https://fonts.gstatic.com/s/imfellenglish/v7/xwIisCqGFi8pff-oa9uSVAkYLEKE0CJQa8tfZYc_plY.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Kelly Slab",
src: "url(https://fonts.gstatic.com/s/kellyslab/v15/-W_7XJX0Rz3cxUnJC5t6fkQLfg.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Kranky",
src: "url(https://fonts.gstatic.com/s/kranky/v24/hESw6XVgJzlPsFn8oR2F.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Lobster Two",
src: "url(https://fonts.gstatic.com/s/lobstertwo/v18/BngMUXZGTXPUvIoyV6yN5-fN5qU.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Kaushan Script",
src: "url(https://fonts.gstatic.com/s/kaushanscript/v6/qx1LSqts-NtiKcLw4N03IEd0sm1ffa_JvZxsF_BEwQk.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Macondo",
src: "url(https://fonts.gstatic.com/s/macondo/v21/RrQQboN9-iB1IXmOe2LE0Q.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "MedievalSharp",
src: "url(https://fonts.gstatic.com/s/medievalsharp/v9/EvOJzAlL3oU5AQl2mP5KdgptMqhwMg.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Metal Mania",
src: "url(https://fonts.gstatic.com/s/metalmania/v22/RWmMoKWb4e8kqMfBUdPFJdXFiaQ.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Metamorphous",
src: "url(https://fonts.gstatic.com/s/metamorphous/v7/Wnz8HA03aAXcC39ZEX5y133EOyqs.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Montez",
src: "url(https://fonts.gstatic.com/s/montez/v8/aq8el3-0osHIcFK6bXAPkw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Nova Script",
src: "url(https://fonts.gstatic.com/s/novascript/v10/7Au7p_IpkSWSTWaFWkumvlQKGFw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Orbitron",
src: "url(https://fonts.gstatic.com/s/orbitron/v9/HmnHiRzvcnQr8CjBje6GQvesZW2xOQ-xsNqO47m55DA.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Oregano",
src: "url(https://fonts.gstatic.com/s/oregano/v13/If2IXTPxciS3H4S2oZDVPg.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Pirata One",
src: "url(https://fonts.gstatic.com/s/pirataone/v22/I_urMpiDvgLdLh0fAtofhi-Org.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Sail",
src: "url(https://fonts.gstatic.com/s/sail/v16/DPEjYwiBxwYJJBPJAQ.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Satisfy",
src: "url(https://fonts.gstatic.com/s/satisfy/v8/2OzALGYfHwQjkPYWELy-cw.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Shadows Into Light",
src: "url(https://fonts.gstatic.com/s/shadowsintolight/v7/clhLqOv7MXn459PTh0gXYFK2TSYBz0eNcHnp4YqE4Ts.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
},
{
family: "Tapestry",
src: "url(https://fonts.gstatic.com/s/macondo/v21/RrQQboN9-iB1IXmOe2LE0Q.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Uncial Antiqua",
src: "url(https://fonts.gstatic.com/s/uncialantiqua/v5/N0bM2S5WOex4OUbESzoESK-i-MfWQZQ.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Underdog",
src: "url(https://fonts.gstatic.com/s/underdog/v6/CHygV-jCElj7diMroWSlWV8.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "UnifrakturMaguntia",
src: "url(https://fonts.gstatic.com/s/unifrakturmaguntia/v16/WWXPlieVYwiGNomYU-ciRLRvEmK7oaVemGZM.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
},
{
family: "Yellowtail",
src: "url(https://fonts.gstatic.com/s/yellowtail/v8/GcIHC9QEwVkrA19LJU1qlPk_vArhqVIZ0nv9q090hN8.woff2)",
unicodeRange:
"U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215"
}
];
declareDefaultFonts(); // execute once on load
function declareFont(font) {
const {family, src, ...rest} = font;
addFontOption(family);
if (!src) return;
const fontFace = new FontFace(family, src, {...rest, display: "block"});
document.fonts.add(fontFace);
}
function declareDefaultFonts() {
fonts.forEach(font => declareFont(font));
}
function getUsedFonts(svg) {
const usedFontFamilies = new Set();
const labelGroups = svg.querySelectorAll("#labels g");
for (const labelGroup of labelGroups) {
const font = labelGroup.getAttribute("font-family");
if (font) usedFontFamilies.add(font);
}
const provinceFont = provs.attr("font-family");
if (provinceFont) usedFontFamilies.add(provinceFont);
const legend = svg.querySelector("#legend");
const legendFont = legend?.getAttribute("font-family");
if (legendFont) usedFontFamilies.add(legendFont);
const usedFonts = fonts.filter(font => usedFontFamilies.has(font.family));
return usedFonts;
}
function addFontOption(family) {
const options = document.getElementById("styleSelectFont");
const option = document.createElement("option");
option.value = family;
option.innerText = family;
option.style.fontFamily = family;
options.add(option);
}
async function fetchGoogleFont(family) {
const url = `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, "+")}`;
try {
const resp = await fetch(url);
const text = await resp.text();
const fontFaceRules = text.match(/font-face\s*{[^}]+}/g);
const fonts = fontFaceRules.map(fontFace => {
const srcURL = fontFace.match(/url\(['"]?(.+?)['"]?\)/)[1];
const src = `url(${srcURL})`;
const unicodeRange = fontFace.match(/unicode-range: (.*?);/)?.[1];
const variant = fontFace.match(/font-style: (.*?);/)?.[1];
const font = {family, src};
if (unicodeRange) font.unicodeRange = unicodeRange;
if (variant && variant !== "normal") font.variant = variant;
return font;
});
return fonts;
} catch (err) {
ERROR && console.error(err);
return null;
}
}
function readBlobAsDataURL(blob) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async function loadFontsAsDataURI(fonts) {
const promises = fonts.map(async font => {
const url = font.src.match(/url\(['"]?(.+?)['"]?\)/)[1];
const resp = await fetch(url);
const blob = await resp.blob();
const dataURL = await readBlobAsDataURL(blob);
return {...font, src: `url('${dataURL}')`};
});
return await Promise.all(promises);
}
async function addGoogleFont(family) {
const fontRanges = await fetchGoogleFont(family);
if (!fontRanges) return tip("Cannot fetch Google font for this value", true, "error", 4000);
tip(`Google font ${family} is loading...`, true, "warn", 4000);
const promises = fontRanges.map(range => {
const {src, unicodeRange, variant} = range;
const fontFace = new FontFace(family, src, {unicodeRange, variant, display: "block"});
return fontFace.load();
});
Promise.all(promises)
.then(fontFaces => {
fontFaces.forEach(fontFace => document.fonts.add(fontFace));
fonts.push(...fontRanges);
tip(`Google font ${family} is added to the list`, true, "success", 4000);
addFontOption(family);
document.getElementById("styleSelectFont").value = family;
changeFont();
})
.catch(err => {
tip(`Failed to load Google font ${family}`, true, "error", 4000);
ERROR && console.error(err);
});
}
function addLocalFont(family) {
fonts.push({family});
const fontFace = new FontFace(family, `local(${family})`, {display: "block"});
document.fonts.add(fontFace);
tip(`Local font ${family} is added to the fonts list`, true, "success", 4000);
addFontOption(family);
document.getElementById("styleSelectFont").value = family;
changeFont();
}
function addWebFont(family, url) {
const src = `url('${url}')`;
fonts.push({family, src});
const fontFace = new FontFace(family, src, {display: "block"});
document.fonts.add(fontFace);
tip(`Font ${family} is added to the list`, true, "success", 4000);
addFontOption(family);
document.getElementById("styleSelectFont").value = family;
changeFont();
}

View file

@ -0,0 +1,539 @@
import {TIME} from "/src/config/logging";
import {createTypedArray} from "/src/utils/arrayUtils";
import {findGridCell} from "/src/utils/graphUtils";
import {byId} from "/src/utils/shorthands";
import {rand, P, getNumberInRange} from "/src/utils/probabilityUtils";
window.HeightmapGenerator = (function () {
let grid = null;
let heights = null;
let blobPower;
let linePower;
const setGraph = graph => {
const {cellsDesired, cells, points} = graph;
heights = cells.h || createTypedArray({maxValue: 100, length: points.length});
blobPower = getBlobPower(cellsDesired);
linePower = getLinePower(cellsDesired);
grid = graph;
};
const getHeights = () => heights;
const clearData = () => {
heights = null;
grid = null;
};
const fromTemplate = (graph, id) => {
const templateString = heightmapTemplates[id]?.template || "";
const steps = templateString.split("\n");
if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`);
setGraph(graph);
for (const step of steps) {
const elements = step.trim().split(" ");
if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`);
addStep(...elements);
}
return heights;
};
const fromPrecreated = (graph, id) => {
return new Promise(resolve => {
// create canvas where 1px corresponts to a cell
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const {cellsX, cellsY} = graph;
canvas.width = cellsX;
canvas.height = cellsY;
// load heightmap into image and render to canvas
const img = new Image();
img.src = `./heightmaps/${id}.png`;
img.onload = () => {
ctx.drawImage(img, 0, 0, cellsX, cellsY);
const imageData = ctx.getImageData(0, 0, cellsX, cellsY);
setGraph(graph);
getHeightsFromImageData(imageData.data);
canvas.remove();
img.remove();
resolve(heights);
};
});
};
const generate = async function (graph) {
TIME && console.time("defineHeightmap");
const id = byId("templateInput").value;
Math.random = aleaPRNG(seed);
const isTemplate = id in heightmapTemplates;
const heights = isTemplate ? fromTemplate(graph, id) : await fromPrecreated(graph, id);
TIME && console.timeEnd("defineHeightmap");
clearData();
return heights;
};
function addStep(tool, a2, a3, a4, a5) {
if (tool === "Hill") return addHill(a2, a3, a4, a5);
if (tool === "Pit") return addPit(a2, a3, a4, a5);
if (tool === "Range") return addRange(a2, a3, a4, a5);
if (tool === "Trough") return addTrough(a2, a3, a4, a5);
if (tool === "Strait") return addStrait(a2, a3);
if (tool === "Mask") return mask(a2);
if (tool === "Invert") return invert(a2, a3);
if (tool === "Add") return modify(a3, +a2, 1);
if (tool === "Multiply") return modify(a3, 0, +a2);
if (tool === "Smooth") return smooth(a2);
}
function getBlobPower(cells) {
const blobPowerMap = {
1000: 0.93,
2000: 0.95,
5000: 0.97,
10000: 0.98,
20000: 0.99,
30000: 0.991,
40000: 0.993,
50000: 0.994,
60000: 0.995,
70000: 0.9955,
80000: 0.996,
90000: 0.9964,
100000: 0.9973
};
return blobPowerMap[cells] || 0.98;
}
function getLinePower() {
const linePowerMap = {
1000: 0.75,
2000: 0.77,
5000: 0.79,
10000: 0.81,
20000: 0.82,
30000: 0.83,
40000: 0.84,
50000: 0.86,
60000: 0.87,
70000: 0.88,
80000: 0.91,
90000: 0.92,
100000: 0.93
};
return linePowerMap[cells] || 0.81;
}
const addHill = (count, height, rangeX, rangeY) => {
count = getNumberInRange(count);
while (count > 0) {
addOneHill();
count--;
}
function addOneHill() {
const change = new Uint8Array(heights.length);
let limit = 0;
let start;
let h = lim(getNumberInRange(height));
do {
const x = getPointInRange(rangeX, graphWidth);
const y = getPointInRange(rangeY, graphHeight);
start = findGridCell(x, y, grid);
limit++;
} while (heights[start] + h > 90 && limit < 50);
change[start] = h;
const queue = [start];
while (queue.length) {
const q = queue.shift();
for (const c of grid.cells.c[q]) {
if (change[c]) continue;
change[c] = change[q] ** blobPower * (Math.random() * 0.2 + 0.9);
if (change[c] > 1) queue.push(c);
}
}
heights = heights.map((h, i) => lim(h + change[i]));
}
};
const addPit = (count, height, rangeX, rangeY) => {
count = getNumberInRange(count);
while (count > 0) {
addOnePit();
count--;
}
function addOnePit() {
const used = new Uint8Array(heights.length);
let limit = 0,
start;
let h = lim(getNumberInRange(height));
do {
const x = getPointInRange(rangeX, graphWidth);
const y = getPointInRange(rangeY, graphHeight);
start = findGridCell(x, y, grid);
limit++;
} while (heights[start] < 20 && limit < 50);
const queue = [start];
while (queue.length) {
const q = queue.shift();
h = h ** blobPower * (Math.random() * 0.2 + 0.9);
if (h < 1) return;
grid.cells.c[q].forEach(function (c, i) {
if (used[c]) return;
heights[c] = lim(heights[c] - h * (Math.random() * 0.2 + 0.9));
used[c] = 1;
queue.push(c);
});
}
}
};
const addRange = (count, height, rangeX, rangeY) => {
count = getNumberInRange(count);
while (count > 0) {
addOneRange();
count--;
}
function addOneRange() {
const used = new Uint8Array(heights.length);
let h = lim(getNumberInRange(height));
// find start and end points
const startX = getPointInRange(rangeX, graphWidth);
const startY = getPointInRange(rangeY, graphHeight);
let dist = 0,
limit = 0,
endX,
endY;
do {
endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1;
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50);
const startCell = findGridCell(startX, startY, grid);
const endCell = findGridCell(endX, endY, grid);
let range = getRange(startCell, endCell);
// get main ridge
function getRange(cur, end) {
const range = [cur];
const p = grid.points;
used[cur] = 1;
while (cur !== end) {
let min = Infinity;
grid.cells.c[cur].forEach(function (e) {
if (used[e]) return;
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > 0.85) diff = diff / 2;
if (diff < min) {
min = diff;
cur = e;
}
});
if (min === Infinity) return range;
range.push(cur);
used[cur] = 1;
}
return range;
}
// add height to ridge and cells around
let queue = range.slice(),
i = 0;
while (queue.length) {
const frontier = queue.slice();
(queue = []), i++;
frontier.forEach(i => {
heights[i] = lim(heights[i] + h * (Math.random() * 0.3 + 0.85));
});
h = h ** linePower - 1;
if (h < 2) break;
frontier.forEach(f => {
grid.cells.c[f].forEach(i => {
if (!used[i]) {
queue.push(i);
used[i] = 1;
}
});
});
}
// generate prominences
range.forEach((cur, d) => {
if (d % 6 !== 0) return;
for (const l of d3.range(i)) {
const min = grid.cells.c[cur][d3.scan(grid.cells.c[cur], (a, b) => heights[a] - heights[b])]; // downhill cell
heights[min] = (heights[cur] * 2 + heights[min]) / 3;
cur = min;
}
});
}
};
const addTrough = (count, height, rangeX, rangeY) => {
count = getNumberInRange(count);
while (count > 0) {
addOneTrough();
count--;
}
function addOneTrough() {
const used = new Uint8Array(heights.length);
let h = lim(getNumberInRange(height));
// find start and end points
let limit = 0,
startX,
startY,
start,
dist = 0,
endX,
endY;
do {
startX = getPointInRange(rangeX, graphWidth);
startY = getPointInRange(rangeY, graphHeight);
start = findGridCell(startX, startY, grid);
limit++;
} while (heights[start] < 20 && limit < 50);
limit = 0;
do {
endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1;
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50);
let range = getRange(start, findGridCell(endX, endY, grid));
// get main ridge
function getRange(cur, end) {
const range = [cur];
const p = grid.points;
used[cur] = 1;
while (cur !== end) {
let min = Infinity;
grid.cells.c[cur].forEach(function (e) {
if (used[e]) return;
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > 0.8) diff = diff / 2;
if (diff < min) {
min = diff;
cur = e;
}
});
if (min === Infinity) return range;
range.push(cur);
used[cur] = 1;
}
return range;
}
// add height to ridge and cells around
let queue = range.slice(),
i = 0;
while (queue.length) {
const frontier = queue.slice();
(queue = []), i++;
frontier.forEach(i => {
heights[i] = lim(heights[i] - h * (Math.random() * 0.3 + 0.85));
});
h = h ** linePower - 1;
if (h < 2) break;
frontier.forEach(f => {
grid.cells.c[f].forEach(i => {
if (!used[i]) {
queue.push(i);
used[i] = 1;
}
});
});
}
// generate prominences
range.forEach((cur, d) => {
if (d % 6 !== 0) return;
for (const l of d3.range(i)) {
const min = grid.cells.c[cur][d3.scan(grid.cells.c[cur], (a, b) => heights[a] - heights[b])]; // downhill cell
//debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1);
heights[min] = (heights[cur] * 2 + heights[min]) / 3;
cur = min;
}
});
}
};
const addStrait = (width, direction = "vertical") => {
width = Math.min(getNumberInRange(width), grid.cellsX / 3);
if (width < 1 && P(width)) return;
const used = new Uint8Array(heights.length);
const vert = direction === "vertical";
const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5;
const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3);
const endX = vert
? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2)
: graphWidth - 5;
const endY = vert
? graphHeight - 5
: Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2);
const start = findGridCell(startX, startY, grid);
const end = findGridCell(endX, endY, grid);
let range = getRange(start, end);
const query = [];
function getRange(cur, end) {
const range = [];
const p = grid.points;
while (cur !== end) {
let min = Infinity;
grid.cells.c[cur].forEach(function (e) {
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
if (Math.random() > 0.8) diff = diff / 2;
if (diff < min) {
min = diff;
cur = e;
}
});
range.push(cur);
}
return range;
}
const step = 0.1 / width;
while (width > 0) {
const exp = 0.9 - step * width;
range.forEach(function (r) {
grid.cells.c[r].forEach(function (e) {
if (used[e]) return;
used[e] = 1;
query.push(e);
heights[e] **= exp;
if (heights[e] > 100) heights[e] = 5;
});
});
range = query.slice();
width--;
}
};
const modify = (range, add, mult, power) => {
const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0];
const max = range === "land" || range === "all" ? 100 : +range.split("-")[1];
const isLand = min === 20;
heights = heights.map(h => {
if (h < min || h > max) return h;
if (add) h = isLand ? Math.max(h + add, 20) : h + add;
if (mult !== 1) h = isLand ? (h - 20) * mult + 20 : h * mult;
if (power) h = isLand ? (h - 20) ** power + 20 : h ** power;
return lim(h);
});
};
const smooth = (fr = 2, add = 0) => {
heights = heights.map((h, i) => {
const a = [h];
grid.cells.c[i].forEach(c => a.push(heights[c]));
if (fr === 1) return d3.mean(a) + add;
return lim((h * (fr - 1) + d3.mean(a) + add) / fr);
});
};
const mask = (power = 1) => {
const fr = power ? Math.abs(power) : 1;
heights = heights.map((h, i) => {
const [x, y] = grid.points[i];
const nx = (2 * x) / graphWidth - 1; // [-1, 1], 0 is center
const ny = (2 * y) / graphHeight - 1; // [-1, 1], 0 is center
let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge
if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge
const masked = h * distance;
return lim((h * (fr - 1) + masked) / fr);
});
};
const invert = (count, axes) => {
if (!P(count)) return;
const invertX = axes !== "y";
const invertY = axes !== "x";
const {cellsX, cellsY} = grid;
const inverted = heights.map((h, i) => {
const x = i % cellsX;
const y = Math.floor(i / cellsX);
const nx = invertX ? cellsX - x - 1 : x;
const ny = invertY ? cellsY - y - 1 : y;
const invertedI = nx + ny * cellsX;
return heights[invertedI];
});
heights = inverted;
};
function getPointInRange(range, length) {
if (typeof range !== "string") {
ERROR && console.error("Range should be a string");
return;
}
const min = range.split("-")[0] / 100 || 0;
const max = range.split("-")[1] / 100 || min;
return rand(min * length, max * length);
}
function getHeightsFromImageData(imageData) {
for (let i = 0; i < heights.length; i++) {
const lightness = imageData[i * 4] / 255;
const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8;
heights[i] = minmax(Math.floor(powered * 100), 0, 100);
}
}
return {
setGraph,
getHeights,
generate,
fromTemplate,
fromPrecreated,
addHill,
addRange,
addTrough,
addStrait,
addPit,
smooth,
modify,
mask,
invert
};
})();

140
src/modules/io/cloud.js Normal file
View file

@ -0,0 +1,140 @@
import {tip} from "/src/scripts/tooltips";
/*
Cloud provider implementations (Dropbox only as now)
provider Interface:
name: name of the provider
async auth(): authenticate and get access tokens from provider
async save(filename): save map file to provider as filename
async load(filename): load filename from provider
async list(): list available filenames at provider
async getLink(filePath): get shareable link for file
restore(): restore access tokens from storage if possible
*/
window.Cloud = (function () {
// helpers to use in providers for token handling
const lSKey = x => `auth-${x}`;
const setToken = (prov, key) => localStorage.setItem(lSKey(prov), key);
const getToken = prov => localStorage.getItem(lSKey(prov));
/**********************************************************/
/* Dropbox provider */
/**********************************************************/
const DBP = {
name: "dropbox",
clientId: "pdr9ae64ip0qno4",
authWindow: undefined,
token: null, // Access token
api: null,
async call(name, param) {
try {
if (!this.api) await this.initialize();
return await this.api[name](param);
} catch (e) {
if (e.name !== "DropboxResponseError") throw e;
await this.auth(); // retry with auth
return await this.api[name](param);
}
},
initialize() {
const token = getToken(this.name);
if (token) {
return this.connect(token);
} else {
return this.auth();
}
},
async connect(token) {
await import("../../libs/dropbox-sdk.min.js");
const auth = new Dropbox.DropboxAuth({clientId: this.clientId});
auth.setAccessToken(token);
this.api = new Dropbox.Dropbox({auth});
},
async save(fileName, contents) {
const resp = await this.call("filesUpload", {path: "/" + fileName, contents});
DEBUG && console.log("Dropbox response:", resp);
return true;
},
async load(path) {
const resp = await this.call("filesDownload", {path});
const blob = resp.result.fileBlob;
if (!blob) throw new Error("Invalid response from dropbox.");
return blob;
},
async list() {
const resp = await this.call("filesListFolder", {path: ""});
const filesData = resp.result.entries.map(({name, client_modified, size, path_lower}) => ({
name: name,
updated: client_modified,
size,
path: path_lower
}));
return filesData.filter(({size}) => size).reverse();
},
auth() {
const width = 640;
const height = 480;
const left = window.innerWidth / 2 - width / 2;
const top = window.innerHeight / 2 - height / 2.5;
this.authWindow = window.open("./dropbox.html", "auth", `width=640, height=${height}, top=${top}, left=${left}}`);
return new Promise((resolve, reject) => {
const watchDog = setTimeout(() => {
this.authWindow.close();
reject(new Error("Timeout. No auth for Dropbox"));
}, 120 * 1000);
window.addEventListener("dropboxauth", e => {
clearTimeout(watchDog);
resolve();
});
});
},
// Callback function for auth window
async setDropBoxToken(token) {
DEBUG && console.log("Access token:", token);
setToken(this.name, token);
await this.connect(token);
this.authWindow.close();
window.dispatchEvent(new Event("dropboxauth"));
},
returnError(errorDescription) {
console.error(errorDescription);
tip(errorDescription.replaceAll("+", " "), true, "error", 4000);
this.authWindow.close();
},
async getLink(path) {
// return existitng shared link
const sharedLinks = await this.call("sharingListSharedLinks", {path});
if (sharedLinks.result.links.length) return resp.result.links[0].url;
// create new shared link
const settings = {
require_password: false,
audience: "public",
access: "viewer",
requested_visibility: "public",
allow_download: true
};
const resp = await this.call("sharingCreateSharedLinkWithSettings", {path, settings});
DEBUG && console.log("Dropbox link object:", resp.result);
return resp.result.url;
}
};
const providers = {dropbox: DBP};
return {providers};
})();

521
src/modules/io/export.js Normal file
View file

@ -0,0 +1,521 @@
import {getGridPolygon} from "/src/utils/graphUtils";
import {unique} from "/src/utils/arrayUtils";
import {tip} from "/src/scripts/tooltips";
import {getCoordinates} from "/src/utils/coordinateUtils";
import {rn} from "/src/utils/numberUtils";
import {getBase64} from "/src/utils/functionUtils";
// download map as SVG
async function saveSVG() {
TIME && console.time("saveSVG");
const url = await getMapURL("svg", {fullMap: true});
const link = document.createElement("a");
link.download = getFileName() + ".svg";
link.href = url;
link.click();
const tooltip = `${link.download} is saved. Open "Downloads" screen (CTRL + J) to check. You can image scale in options`;
tip(tooltip, true, "success", 5000);
TIME && console.timeEnd("saveSVG");
}
// download map as PNG
async function savePNG() {
TIME && console.time("savePNG");
const url = await getMapURL("png");
const link = document.createElement("a");
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = svgWidth * pngResolutionInput.value;
canvas.height = svgHeight * pngResolutionInput.value;
const img = new Image();
img.src = url;
img.onload = function () {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
link.download = getFileName() + ".png";
canvas.toBlob(function (blob) {
link.href = window.URL.createObjectURL(blob);
link.click();
window.setTimeout(function () {
canvas.remove();
window.URL.revokeObjectURL(link.href);
tip(
`${link.download} is saved. Open "Downloads" screen (crtl + J) to check. You can set image scale in options`,
true,
"success",
5000
);
}, 1000);
});
};
TIME && console.timeEnd("savePNG");
}
// download map as JPEG
async function saveJPEG() {
TIME && console.time("saveJPEG");
const url = await getMapURL("png");
const canvas = document.createElement("canvas");
canvas.width = svgWidth * pngResolutionInput.value;
canvas.height = svgHeight * pngResolutionInput.value;
const img = new Image();
img.src = url;
img.onload = async function () {
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
const quality = Math.min(rn(1 - pngResolutionInput.value / 20, 2), 0.92);
const URL = await canvas.toDataURL("image/jpeg", quality);
const link = document.createElement("a");
link.download = getFileName() + ".jpeg";
link.href = URL;
link.click();
tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
window.setTimeout(() => window.URL.revokeObjectURL(URL), 5000);
};
TIME && console.timeEnd("saveJPEG");
}
// download map as png tiles
async function saveTiles() {
return new Promise(async (resolve, reject) => {
// download schema
const urlSchema = await getMapURL("tiles", {debug: true, fullMap: true});
await import("../../libs/jszip.min.js");
const zip = new window.JSZip();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = graphWidth;
canvas.height = graphHeight;
const imgSchema = new Image();
imgSchema.src = urlSchema;
imgSchema.onload = function () {
ctx.drawImage(imgSchema, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => zip.file(`fmg_tile_schema.png`, blob));
};
// download tiles
const url = await getMapURL("tiles", {fullMap: true});
const tilesX = +document.getElementById("tileColsInput").value;
const tilesY = +document.getElementById("tileRowsInput").value;
const scale = +document.getElementById("tileScaleInput").value;
const tileW = (graphWidth / tilesX) | 0;
const tileH = (graphHeight / tilesY) | 0;
const tolesTotal = tilesX * tilesY;
const width = graphWidth * scale;
const height = width * (tileH / tileW);
canvas.width = width;
canvas.height = height;
let loaded = 0;
const img = new Image();
img.src = url;
img.onload = function () {
for (let y = 0, i = 0; y + tileH <= graphHeight; y += tileH) {
for (let x = 0; x + tileW <= graphWidth; x += tileW, i++) {
ctx.drawImage(img, x, y, tileW, tileH, 0, 0, width, height);
const name = `fmg_tile_${i}.png`;
canvas.toBlob(blob => {
zip.file(name, blob);
loaded += 1;
if (loaded === tolesTotal) return downloadZip();
});
}
}
};
function downloadZip() {
const name = `${getFileName()}.zip`;
zip.generateAsync({type: "blob"}).then(blob => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = name;
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
resolve(true);
});
}
});
}
// parse map svg to object url
async function getMapURL(type, options = {}) {
const {
debug = false,
globe = false,
noLabels = false,
noWater = false,
noScaleBar = false,
noIce = false,
fullMap = false
} = options;
if (fullMap) drawScaleBar(1);
const cloneEl = document.getElementById("map").cloneNode(true); // clone svg
cloneEl.id = "fantasyMap";
document.body.appendChild(cloneEl);
const clone = d3.select(cloneEl);
if (!debug) clone.select("#debug")?.remove();
const cloneDefs = cloneEl.getElementsByTagName("defs")[0];
const svgDefs = document.getElementById("defElements");
const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
if (isFirefox && type === "mesh") clone.select("#oceanPattern")?.remove();
if (globe) clone.select("#scaleBar")?.remove();
if (noLabels) {
clone.select("#labels #states")?.remove();
clone.select("#labels #burgLabels")?.remove();
clone.select("#icons #burgIcons")?.remove();
}
if (noWater) {
clone.select("#oceanBase").attr("opacity", 0);
clone.select("#oceanPattern").attr("opacity", 0);
}
if (noScaleBar) clone.select("#scaleBar")?.remove();
if (noIce) clone.select("#ice")?.remove();
if (fullMap) {
// reset transform to show the whole map
clone.attr("width", graphWidth).attr("height", graphHeight);
clone.select("#viewbox").attr("transform", null);
drawScaleBar(scale);
}
if (type === "svg") removeUnusedElements(clone);
if (customization && type === "mesh") updateMeshCells(clone);
inlineStyle(clone);
// remove unused filters
const filters = cloneEl.querySelectorAll("filter");
for (let i = 0; i < filters.length; i++) {
const id = filters[i].id;
if (cloneEl.querySelector("[filter='url(#" + id + ")']")) continue;
if (cloneEl.getAttribute("filter") === "url(#" + id + ")") continue;
filters[i].remove();
}
// remove unused patterns
const patterns = cloneEl.querySelectorAll("pattern");
for (let i = 0; i < patterns.length; i++) {
const id = patterns[i].id;
if (cloneEl.querySelector("[fill='url(#" + id + ")']")) continue;
patterns[i].remove();
}
// remove unused symbols
const symbols = cloneEl.querySelectorAll("symbol");
for (let i = 0; i < symbols.length; i++) {
const id = symbols[i].id;
if (cloneEl.querySelector("use[*|href='#" + id + "']")) continue;
symbols[i].remove();
}
// add displayed emblems
if (layerIsOn("toggleEmblems") && emblems.selectAll("use").size()) {
cloneEl
.getElementById("emblems")
?.querySelectorAll("use")
.forEach(el => {
const href = el.getAttribute("href") || el.getAttribute("xlink:href");
if (!href) return;
const emblem = document.getElementById(href.slice(1));
if (emblem) cloneDefs.append(emblem.cloneNode(true));
});
} else {
cloneDefs.querySelector("#defs-emblems")?.remove();
}
// replace ocean pattern href to base64
if (location.hostname && cloneEl.getElementById("oceanicPattern")) {
const el = cloneEl.getElementById("oceanicPattern");
const url = el.getAttribute("href");
await new Promise(resolve => {
getBase64(url, base64 => {
el.setAttribute("href", base64);
resolve();
});
});
}
// add relief icons
if (cloneEl.getElementById("terrain")) {
const uniqueElements = new Set();
const terrainNodes = cloneEl.getElementById("terrain").childNodes;
for (let i = 0; i < terrainNodes.length; i++) {
const href = terrainNodes[i].getAttribute("href") || terrainNodes[i].getAttribute("xlink:href");
uniqueElements.add(href);
}
const defsRelief = svgDefs.getElementById("defs-relief");
for (const terrain of [...uniqueElements]) {
const element = defsRelief.querySelector(terrain);
if (element) cloneDefs.appendChild(element.cloneNode(true));
}
}
// add wind rose
if (cloneEl.getElementById("compass")) {
const rose = svgDefs.getElementById("rose");
if (rose) cloneDefs.appendChild(rose.cloneNode(true));
}
// add port icon
if (cloneEl.getElementById("anchors")) {
const anchor = svgDefs.getElementById("icon-anchor");
if (anchor) cloneDefs.appendChild(anchor.cloneNode(true));
}
// add grid pattern
if (cloneEl.getElementById("gridOverlay")?.hasChildNodes()) {
const type = cloneEl.getElementById("gridOverlay").getAttribute("type");
const pattern = svgDefs.getElementById("pattern_" + type);
if (pattern) cloneDefs.appendChild(pattern.cloneNode(true));
}
if (!cloneEl.getElementById("fogging-cont")) cloneEl.getElementById("fog")?.remove(); // remove unused fog
if (!cloneEl.getElementById("regions")) cloneEl.getElementById("statePaths")?.remove(); // removed unused statePaths
if (!cloneEl.getElementById("labels")) cloneEl.getElementById("textPaths")?.remove(); // removed unused textPaths
// add armies style
if (cloneEl.getElementById("armies")) {
cloneEl.insertAdjacentHTML(
"afterbegin",
"<style>#armies text {stroke: none; fill: #fff; text-shadow: 0 0 4px #000; dominant-baseline: central; text-anchor: middle; font-family: Helvetica; fill-opacity: 1;}#armies text.regimentIcon {font-size: .8em;}</style>"
);
}
// add xlink: for href to support svg 1.1
if (type === "svg") {
cloneEl.querySelectorAll("[href]").forEach(el => {
const href = el.getAttribute("href");
el.removeAttribute("href");
el.setAttribute("xlink:href", href);
});
}
// add hatchings
const hatchingUsers = cloneEl.querySelectorAll(`[fill^='url(#hatch']`);
const hatchingFills = unique(Array.from(hatchingUsers).map(el => el.getAttribute("fill")));
const hatchingIds = hatchingFills.map(fill => fill.slice(5, -1));
for (const hatchingId of hatchingIds) {
const hatching = svgDefs.getElementById(hatchingId);
if (hatching) cloneDefs.appendChild(hatching.cloneNode(true));
}
// load fonts
const usedFonts = getUsedFonts(cloneEl);
const fontsToLoad = usedFonts.filter(font => font.src);
if (fontsToLoad.length) {
const dataURLfonts = await loadFontsAsDataURI(fontsToLoad);
const fontFaces = dataURLfonts
.map(({family, src, unicodeRange = "", variant = "normal"}) => {
return `@font-face {font-family: "${family}"; src: ${src}; unicode-range: ${unicodeRange}; font-variant: ${variant};}`;
})
.join("\n");
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = fontFaces;
cloneEl.querySelector("defs").appendChild(style);
}
clone.remove();
const serialized =
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>` + new XMLSerializer().serializeToString(cloneEl);
const blob = new Blob([serialized], {type: "image/svg+xml;charset=utf-8"});
const url = window.URL.createObjectURL(blob);
window.setTimeout(() => window.URL.revokeObjectURL(url), 5000);
return url;
}
// remove hidden g elements and g elements without children to make downloaded svg smaller in size
function removeUnusedElements(clone) {
if (!terrain.selectAll("use").size()) clone.select("#defs-relief")?.remove();
for (let empty = 1; empty; ) {
empty = 0;
clone.selectAll("g").each(function () {
if (!this.hasChildNodes() || this.style.display === "none" || this.classList.contains("hidden")) {
empty++;
this.remove();
}
if (this.hasAttribute("display") && this.style.display === "inline") this.removeAttribute("display");
});
}
}
function updateMeshCells(clone) {
const data = renderOcean.checked ? grid.cells.i : grid.cells.i.filter(i => grid.cells.h[i] >= 20);
const scheme = getColorScheme(terrs.attr("scheme"));
clone.select("#heights").attr("filter", "url(#blur1)");
clone
.select("#heights")
.selectAll("polygon")
.data(data)
.join("polygon")
.attr("points", d => getGridPolygon(d))
.attr("id", d => "cell" + d)
.attr("stroke", d => getColor(grid.cells.h[d], scheme));
}
// for each g element get inline style
function inlineStyle(clone) {
const emptyG = clone.append("g").node();
const defaultStyles = window.getComputedStyle(emptyG);
clone.selectAll("g, #ruler *, #scaleBar > text").each(function () {
const compStyle = window.getComputedStyle(this);
let style = "";
for (let i = 0; i < compStyle.length; i++) {
const key = compStyle[i];
const value = compStyle.getPropertyValue(key);
// Firefox mask hack
if (key === "mask-image" && value !== defaultStyles.getPropertyValue(key)) {
style += "mask-image: url('#land');";
continue;
}
if (key === "cursor") continue; // cursor should be default
if (this.hasAttribute(key)) continue; // don't add style if there is the same attribute
if (value === defaultStyles.getPropertyValue(key)) continue;
style += key + ":" + value + ";";
}
for (const key in compStyle) {
const value = compStyle.getPropertyValue(key);
if (key === "cursor") continue; // cursor should be default
if (this.hasAttribute(key)) continue; // don't add style if there is the same attribute
if (value === defaultStyles.getPropertyValue(key)) continue;
style += key + ":" + value + ";";
}
if (style != "") this.setAttribute("style", style);
});
emptyG.remove();
}
function saveGeoJSON_Cells() {
const json = {type: "FeatureCollection", features: []};
const cells = pack.cells;
const getPopulation = i => {
const [r, u] = getCellPopulation(i);
return rn(r + u);
};
const getHeight = i => parseInt(getFriendlyHeight([cells.p[i][0], cells.p[i][1]]));
cells.i.forEach(i => {
const coordinates = getCellCoordinates(cells.v[i]);
const height = getHeight(i);
const biome = cells.biome[i];
const type = pack.features[cells.f[i]].type;
const population = getPopulation(i);
const state = cells.state[i];
const province = cells.province[i];
const culture = cells.culture[i];
const religion = cells.religion[i];
const neighbors = cells.c[i];
const properties = {id: i, height, biome, type, population, state, province, culture, religion, neighbors};
const feature = {type: "Feature", geometry: {type: "Polygon", coordinates}, properties};
json.features.push(feature);
});
const fileName = getFileName("Cells") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function saveGeoJSON_Routes() {
const json = {type: "FeatureCollection", features: []};
routes.selectAll("g > path").each(function () {
const coordinates = getRoutePoints(this);
const id = this.id;
const type = this.parentElement.id;
const feature = {type: "Feature", geometry: {type: "LineString", coordinates}, properties: {id, type}};
json.features.push(feature);
});
const fileName = getFileName("Routes") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function saveGeoJSON_Rivers() {
const json = {type: "FeatureCollection", features: []};
rivers.selectAll("path").each(function () {
const river = pack.rivers.find(r => r.i === +this.id.slice(5));
if (!river) return;
const coordinates = getRiverPoints(this);
const properties = {...river, id: this.id};
const feature = {type: "Feature", geometry: {type: "LineString", coordinates}, properties};
json.features.push(feature);
});
const fileName = getFileName("Rivers") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function saveGeoJSON_Markers() {
const features = pack.markers.map(marker => {
const {i, type, icon, x, y, size, fill, stroke} = marker;
const coordinates = getCoordinates(x, y, 4);
const id = `marker${i}`;
const note = notes.find(note => note.id === id);
const properties = {id, type, icon, ...note, size, fill, stroke};
return {type: "Feature", geometry: {type: "Point", coordinates}, properties};
});
const json = {type: "FeatureCollection", features};
const fileName = getFileName("Markers") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function getCellCoordinates(vertices) {
const p = pack.vertices.p;
const coordinates = vertices.map(n => getCoordinates(p[n][0], p[n][1], 2));
return [coordinates.concat([coordinates[0]])];
}
function getRoutePoints(node) {
let points = [];
const l = node.getTotalLength();
const increment = l / Math.ceil(l / 2);
for (let i = 0; i <= l; i += increment) {
const p = node.getPointAtLength(i);
points.push(getCoordinates(p.x, p.y, 4));
}
return points;
}
function getRiverPoints(node) {
let points = [];
const l = node.getTotalLength() / 2; // half-length
const increment = 0.25; // defines density of points
for (let i = l, c = i; i >= 0; i -= increment, c += increment) {
const p1 = node.getPointAtLength(i);
const p2 = node.getPointAtLength(c);
const [x, y] = getCoordinates((p1.x + p2.x) / 2, (p1.y + p2.y) / 2, 4);
points.push([x, y]);
}
return points;
}

24
src/modules/io/formats.js Normal file
View file

@ -0,0 +1,24 @@
"use strict";
window.Formats = (function () {
async function csvParser(file, separator = ",") {
const txt = await file.text();
const rows = txt.split("\n");
const headers = rows
.shift()
.split(separator)
.map(x => x.toLowerCase());
const data = rows.filter(a => a.trim() !== "").map(r => r.split(separator));
return {
headers,
data,
iterator: function* (sortf) {
const dataset = sortf ? this.data.sort(sortf) : this.data;
for (const d of dataset) yield Object.fromEntries(d.map((a, i) => [this.headers[i], a]));
}
};
}
return {csvParser};
})();

615
src/modules/io/load.js Normal file
View file

@ -0,0 +1,615 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {calculateVoronoi, findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {tip} from "/src/scripts/tooltips";
import {parseError} from "/src/utils/errorUtils";
import {rn, minmax} from "/src/utils/numberUtils";
import {link} from "/src/utils/linkUtils";
import {ldb} from "/src/scripts/indexedDB";
function quickLoad() {
ldb.get("lastMap", blob => {
if (blob) {
loadMapPrompt(blob);
} else {
tip("No map stored. Save map to storage first", true, "error", 2000);
ERROR && console.error("No map stored");
}
});
}
async function loadFromDropbox() {
const mapPath = document.getElementById("loadFromDropboxSelect")?.value;
DEBUG && console.log("Loading map from Dropbox:", mapPath);
const blob = await Cloud.providers.dropbox.load(mapPath);
uploadMap(blob);
}
async function createSharableDropboxLink() {
const mapFile = document.querySelector("#loadFromDropbox select").value;
const sharableLink = document.getElementById("sharableLink");
const sharableLinkContainer = document.getElementById("sharableLinkContainer");
let url;
try {
url = await Cloud.providers.dropbox.getLink(mapFile);
} catch {
return tip("Dropbox API error. Can not create link.", true, "error", 2000);
}
const fmg = window.location.href.split("?")[0];
const reallink = `${fmg}?maplink=${url}`;
// voodoo magic required by the yellow god of CORS
const link = reallink.replace("www.dropbox.com/s/", "dl.dropboxusercontent.com/1/view/");
const shortLink = link.slice(0, 50) + "...";
sharableLinkContainer.style.display = "block";
sharableLink.innerText = shortLink;
sharableLink.setAttribute("href", link);
}
function loadMapPrompt(blob) {
const workingTime = (Date.now() - last(mapHistory).created) / 60000; // minutes
if (workingTime < 5) {
loadLastSavedMap();
return;
}
alertMessage.innerHTML = /* html */ `Are you sure you want to load saved map?<br />
All unsaved changes made to the current map will be lost`;
$("#alert").dialog({
resizable: false,
title: "Load saved map",
buttons: {
Cancel: function () {
$(this).dialog("close");
},
Load: function () {
loadLastSavedMap();
$(this).dialog("close");
}
}
});
function loadLastSavedMap() {
WARN && console.warn("Load last saved map");
try {
uploadMap(blob);
} catch (error) {
ERROR && console.error(error);
tip("Cannot load last saved map", true, "error", 2000);
}
}
}
function loadMapFromURL(maplink, random) {
const URL = decodeURIComponent(maplink);
fetch(URL, {method: "GET", mode: "cors"})
.then(response => {
if (response.ok) return response.blob();
throw new Error("Cannot load map from URL");
})
.then(blob => uploadMap(blob))
.catch(error => {
showUploadErrorMessage(error.message, URL, random);
if (random) generateMapOnLoad();
});
}
function showUploadErrorMessage(error, URL, random) {
ERROR && console.error(error);
alertMessage.innerHTML = /* html */ `Cannot load map from the ${link(URL, "link provided")}. ${
random ? `A new random map is generated. ` : ""
} Please ensure the
linked file is reachable and CORS is allowed on server side`;
$("#alert").dialog({
title: "Loading error",
width: "32em",
buttons: {
OK: function () {
$(this).dialog("close");
}
}
});
}
function uploadMap(file, callback) {
uploadMap.timeStart = performance.now();
const OLDEST_SUPPORTED_VERSION = 0.7;
const currentVersion = parseFloat(version);
const fileReader = new FileReader();
fileReader.onload = function (fileLoadedEvent) {
if (callback) callback();
document.getElementById("coas").innerHTML = ""; // remove auto-generated emblems
const result = fileLoadedEvent.target.result;
const [mapData, mapVersion] = parseLoadedResult(result);
const isInvalid = !mapData || isNaN(mapVersion) || mapData.length < 26 || !mapData[5];
const isUpdated = mapVersion === currentVersion;
const isAncient = mapVersion < OLDEST_SUPPORTED_VERSION;
const isNewer = mapVersion > currentVersion;
const isOutdated = mapVersion < currentVersion;
if (isInvalid) return showUploadMessage("invalid", mapData, mapVersion);
if (isUpdated) return parseLoadedData(mapData);
if (isAncient) return showUploadMessage("ancient", mapData, mapVersion);
if (isNewer) return showUploadMessage("newer", mapData, mapVersion);
if (isOutdated) return showUploadMessage("outdated", mapData, mapVersion);
};
fileReader.readAsText(file, "UTF-8");
}
function parseLoadedResult(result) {
try {
// data can be in FMG internal format or base64 encoded
const isDelimited = result.substr(0, 10).includes("|");
const decoded = isDelimited ? result : decodeURIComponent(atob(result));
const mapData = decoded.split("\r\n");
const mapVersion = parseFloat(mapData[0].split("|")[0] || mapData[0]);
return [mapData, mapVersion];
} catch (error) {
ERROR && console.error(error);
return [null, null];
}
}
function showUploadMessage(type, mapData, mapVersion) {
const archive = link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog", "archived version");
let message, title, canBeLoaded;
if (type === "invalid") {
message = `The file does not look like a valid <i>.map</i> file.<br>Please check the data format`;
title = "Invalid file";
canBeLoaded = false;
} else if (type === "ancient") {
message = `The map version you are trying to load (${mapVersion}) is too old and cannot be updated to the current version.<br>Please keep using an ${archive}`;
title = "Ancient file";
canBeLoaded = false;
} else if (type === "newer") {
message = `The map version you are trying to load (${mapVersion}) is newer than the current version.<br>Please load the file in the appropriate version`;
title = "Newer file";
canBeLoaded = false;
} else if (type === "outdated") {
message = `The map version (${mapVersion}) does not match the Generator version (${version}).<br>Click OK to get map <b>auto-updated</b>.<br>In case of issues please keep using an ${archive} of the Generator`;
title = "Outdated file";
canBeLoaded = true;
}
alertMessage.innerHTML = message;
const buttons = {
OK: function () {
$(this).dialog("close");
if (canBeLoaded) parseLoadedData(mapData);
}
};
$("#alert").dialog({title, buttons});
}
async function parseLoadedData(data) {
try {
// exit customization
if (window.closeDialogs) closeDialogs();
customization = 0;
if (customizationMenu.offsetParent) styleTab.click();
const params = data[0].split("|");
void (function parseParameters() {
if (params[3]) {
seed = params[3];
optionsSeed.value = seed;
}
if (params[4]) graphWidth = +params[4];
if (params[5]) graphHeight = +params[5];
mapId = params[6] ? +params[6] : Date.now();
})();
INFO && console.group("Loaded Map " + seed);
void (function parseSettings() {
const settings = data[1].split("|");
if (settings[0]) applyOption(distanceUnitInput, settings[0]);
if (settings[1]) distanceScale = distanceScaleInput.value = distanceScaleOutput.value = settings[1];
if (settings[2]) areaUnit.value = settings[2];
if (settings[3]) applyOption(heightUnit, settings[3]);
if (settings[4]) heightExponentInput.value = heightExponentOutput.value = settings[4];
if (settings[5]) temperatureScale.value = settings[5];
if (settings[6]) barSizeInput.value = barSizeOutput.value = settings[6];
if (settings[7] !== undefined) barLabel.value = settings[7];
if (settings[8] !== undefined) barBackOpacity.value = settings[8];
if (settings[9]) barBackColor.value = settings[9];
if (settings[10]) barPosX.value = settings[10];
if (settings[11]) barPosY.value = settings[11];
if (settings[12]) populationRate = populationRateInput.value = populationRateOutput.value = settings[12];
if (settings[13]) urbanization = urbanizationInput.value = urbanizationOutput.value = settings[13];
if (settings[14]) mapSizeInput.value = mapSizeOutput.value = minmax(settings[14], 1, 100);
if (settings[15]) latitudeInput.value = latitudeOutput.value = minmax(settings[15], 0, 100);
if (settings[16]) temperatureEquatorInput.value = temperatureEquatorOutput.value = settings[16];
if (settings[17]) temperaturePoleInput.value = temperaturePoleOutput.value = settings[17];
if (settings[18]) precInput.value = precOutput.value = settings[18];
if (settings[19]) options = JSON.parse(settings[19]);
if (settings[20]) mapName.value = settings[20];
if (settings[21]) hideLabels.checked = +settings[21];
if (settings[22]) stylePreset.value = settings[22];
if (settings[23]) rescaleLabels.checked = +settings[23];
if (settings[24]) urbanDensity = urbanDensityInput.value = urbanDensityOutput.value = +settings[24];
})();
void (function applyOptionsToUI() {
stateLabelsModeInput.value = options.stateLabelsMode;
})();
void (function parseConfiguration() {
if (data[2]) mapCoordinates = JSON.parse(data[2]);
if (data[4]) notes = JSON.parse(data[4]);
if (data[33]) rulers.fromString(data[33]);
if (data[34]) {
const usedFonts = JSON.parse(data[34]);
usedFonts.forEach(usedFont => {
const {family: usedFamily, unicodeRange: usedRange, variant: usedVariant} = usedFont;
const defaultFont = fonts.find(
({family, unicodeRange, variant}) =>
family === usedFamily && unicodeRange === usedRange && variant === usedVariant
);
if (!defaultFont) fonts.push(usedFont);
declareFont(usedFont);
});
}
const biomes = data[3].split("|");
biomesData = applyDefaultBiomesSystem();
biomesData.color = biomes[0].split(",");
biomesData.habitability = biomes[1].split(",").map(h => +h);
biomesData.name = biomes[2].split(",");
// push custom biomes if any
for (let i = biomesData.i.length; i < biomesData.name.length; i++) {
biomesData.i.push(biomesData.i.length);
biomesData.iconsDensity.push(0);
biomesData.icons.push([]);
biomesData.cost.push(50);
}
})();
void (function replaceSVG() {
svg.remove();
document.body.insertAdjacentHTML("afterbegin", data[5]);
})();
void (function redefineElements() {
svg = d3.select("#map");
defs = svg.select("#deftemp");
viewbox = svg.select("#viewbox");
scaleBar = svg.select("#scaleBar");
legend = svg.select("#legend");
ocean = viewbox.select("#ocean");
oceanLayers = ocean.select("#oceanLayers");
oceanPattern = ocean.select("#oceanPattern");
lakes = viewbox.select("#lakes");
landmass = viewbox.select("#landmass");
texture = viewbox.select("#texture");
terrs = viewbox.select("#terrs");
biomes = viewbox.select("#biomes");
ice = viewbox.select("#ice");
cells = viewbox.select("#cells");
gridOverlay = viewbox.select("#gridOverlay");
coordinates = viewbox.select("#coordinates");
compass = viewbox.select("#compass");
rivers = viewbox.select("#rivers");
terrain = viewbox.select("#terrain");
relig = viewbox.select("#relig");
cults = viewbox.select("#cults");
regions = viewbox.select("#regions");
statesBody = regions.select("#statesBody");
statesHalo = regions.select("#statesHalo");
provs = viewbox.select("#provs");
zones = viewbox.select("#zones");
borders = viewbox.select("#borders");
stateBorders = borders.select("#stateBorders");
provinceBorders = borders.select("#provinceBorders");
routes = viewbox.select("#routes");
roads = routes.select("#roads");
trails = routes.select("#trails");
searoutes = routes.select("#searoutes");
temperature = viewbox.select("#temperature");
coastline = viewbox.select("#coastline");
prec = viewbox.select("#prec");
population = viewbox.select("#population");
emblems = viewbox.select("#emblems");
labels = viewbox.select("#labels");
icons = viewbox.select("#icons");
burgIcons = icons.select("#burgIcons");
anchors = icons.select("#anchors");
armies = viewbox.select("#armies");
markers = viewbox.select("#markers");
ruler = viewbox.select("#ruler");
fogging = viewbox.select("#fogging");
debug = viewbox.select("#debug");
burgLabels = labels.select("#burgLabels");
})();
void (function parseGridData() {
grid = JSON.parse(data[6]);
const {cells, vertices} = calculateVoronoi(grid.points, grid.boundary);
grid.cells = cells;
grid.vertices = vertices;
grid.cells.h = Uint8Array.from(data[7].split(","));
grid.cells.prec = Uint8Array.from(data[8].split(","));
grid.cells.f = Uint16Array.from(data[9].split(","));
grid.cells.t = Int8Array.from(data[10].split(","));
grid.cells.temp = Int8Array.from(data[11].split(","));
})();
void (function parsePackData() {
reGraph();
reMarkFeatures();
pack.features = JSON.parse(data[12]);
pack.cultures = JSON.parse(data[13]);
pack.states = JSON.parse(data[14]);
pack.burgs = JSON.parse(data[15]);
pack.religions = data[29] ? JSON.parse(data[29]) : [{i: 0, name: "No religion"}];
pack.provinces = data[30] ? JSON.parse(data[30]) : [0];
pack.rivers = data[32] ? JSON.parse(data[32]) : [];
pack.markers = data[35] ? JSON.parse(data[35]) : [];
const cells = pack.cells;
cells.biome = Uint8Array.from(data[16].split(","));
cells.burg = Uint16Array.from(data[17].split(","));
cells.conf = Uint8Array.from(data[18].split(","));
cells.culture = Uint16Array.from(data[19].split(","));
cells.fl = Uint16Array.from(data[20].split(","));
cells.pop = Float32Array.from(data[21].split(","));
cells.r = Uint16Array.from(data[22].split(","));
cells.road = Uint16Array.from(data[23].split(","));
cells.s = Uint16Array.from(data[24].split(","));
cells.state = Uint16Array.from(data[25].split(","));
cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(cells.i.length);
cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(cells.i.length);
cells.crossroad = data[28] ? Uint16Array.from(data[28].split(",")) : new Uint16Array(cells.i.length);
if (data[31]) {
const namesDL = data[31].split("/");
namesDL.forEach((d, i) => {
const e = d.split("|");
if (!e.length) return;
const b = e[5].split(",").length > 2 || !nameBases[i] ? e[5] : nameBases[i].b;
nameBases[i] = {name: e[0], min: e[1], max: e[2], d: e[3], m: e[4], b};
});
}
})();
void (function restoreLayersState() {
// helper functions
const notHidden = selection => selection.node() && selection.style("display") !== "none";
const hasChildren = selection => selection.node()?.hasChildNodes();
const hasChild = (selection, selector) => selection.node()?.querySelector(selector);
const turnOn = el => document.getElementById(el).classList.remove("buttonoff");
// turn all layers off
document
.getElementById("mapLayers")
.querySelectorAll("li")
.forEach(el => el.classList.add("buttonoff"));
// turn on active layers
if (notHidden(texture) && hasChild(texture, "image")) turnOn("toggleTexture");
if (hasChildren(terrs)) turnOn("toggleHeight");
if (hasChildren(biomes)) turnOn("toggleBiomes");
if (hasChildren(cells)) turnOn("toggleCells");
if (hasChildren(gridOverlay)) turnOn("toggleGrid");
if (hasChildren(coordinates)) turnOn("toggleCoordinates");
if (notHidden(compass) && hasChild(compass, "use")) turnOn("toggleCompass");
if (hasChildren(rivers)) turnOn("toggleRivers");
if (notHidden(terrain) && hasChildren(terrain)) turnOn("toggleRelief");
if (hasChildren(relig)) turnOn("toggleReligions");
if (hasChildren(cults)) turnOn("toggleCultures");
if (hasChildren(statesBody)) turnOn("toggleStates");
if (hasChildren(provs)) turnOn("toggleProvinces");
if (hasChildren(zones) && notHidden(zones)) turnOn("toggleZones");
if (notHidden(borders) && hasChild(compass, "use")) turnOn("toggleBorders");
if (notHidden(routes) && hasChild(routes, "path")) turnOn("toggleRoutes");
if (hasChildren(temperature)) turnOn("toggleTemp");
if (hasChild(population, "line")) turnOn("togglePopulation");
if (hasChildren(ice)) turnOn("toggleIce");
if (hasChild(prec, "circle")) turnOn("togglePrec");
if (notHidden(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems");
if (notHidden(labels)) turnOn("toggleLabels");
if (notHidden(icons)) turnOn("toggleIcons");
if (hasChildren(armies) && notHidden(armies)) turnOn("toggleMilitary");
if (hasChildren(markers)) turnOn("toggleMarkers");
if (notHidden(ruler)) turnOn("toggleRulers");
if (notHidden(scaleBar)) turnOn("toggleScaleBar");
getCurrentPreset();
})();
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]);
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=06062022");
resolveVersionConflicts(versionNumber);
}
void (function checkDataIntegrity() {
const cells = pack.cells;
if (pack.cells.i.length !== pack.cells.state.length) {
ERROR &&
console.error(
"Striping issue. Map data is corrupted. The only solution is to edit the heightmap in erase mode"
);
}
const invalidStates = [...new Set(cells.state)].filter(s => !pack.states[s] || pack.states[s].removed);
invalidStates.forEach(s => {
const invalidCells = cells.i.filter(i => cells.state[i] === s);
invalidCells.forEach(i => (cells.state[i] = 0));
ERROR && console.error("Data Integrity Check. Invalid state", s, "is assigned to cells", invalidCells);
});
const invalidProvinces = [...new Set(cells.province)].filter(
p => p && (!pack.provinces[p] || pack.provinces[p].removed)
);
invalidProvinces.forEach(p => {
const invalidCells = cells.i.filter(i => cells.province[i] === p);
invalidCells.forEach(i => (cells.province[i] = 0));
ERROR && console.error("Data Integrity Check. Invalid province", p, "is assigned to cells", invalidCells);
});
const invalidCultures = [...new Set(cells.culture)].filter(c => !pack.cultures[c] || pack.cultures[c].removed);
invalidCultures.forEach(c => {
const invalidCells = cells.i.filter(i => cells.culture[i] === c);
invalidCells.forEach(i => (cells.province[i] = 0));
ERROR && console.error("Data Integrity Check. Invalid culture", c, "is assigned to cells", invalidCells);
});
const invalidReligions = [...new Set(cells.religion)].filter(
r => !pack.religions[r] || pack.religions[r].removed
);
invalidReligions.forEach(r => {
const invalidCells = cells.i.filter(i => cells.religion[i] === r);
invalidCells.forEach(i => (cells.religion[i] = 0));
ERROR && console.error("Data Integrity Check. Invalid religion", r, "is assigned to cells", invalidCells);
});
const invalidFeatures = [...new Set(cells.f)].filter(f => f && !pack.features[f]);
invalidFeatures.forEach(f => {
const invalidCells = cells.i.filter(i => cells.f[i] === f);
// No fix as for now
ERROR && console.error("Data Integrity Check. Invalid feature", f, "is assigned to cells", invalidCells);
});
const invalidBurgs = [...new Set(cells.burg)].filter(b => b && (!pack.burgs[b] || pack.burgs[b].removed));
invalidBurgs.forEach(b => {
const invalidCells = cells.i.filter(i => cells.burg[i] === b);
invalidCells.forEach(i => (cells.burg[i] = 0));
ERROR && console.error("Data Integrity Check. Invalid burg", b, "is assigned to cells", invalidCells);
});
const invalidRivers = [...new Set(cells.r)].filter(r => r && !pack.rivers.find(river => river.i === r));
invalidRivers.forEach(r => {
const invalidCells = cells.i.filter(i => cells.r[i] === r);
invalidCells.forEach(i => (cells.r[i] = 0));
rivers.select("river" + r).remove();
ERROR && console.error("Data Integrity Check. Invalid river", r, "is assigned to cells", invalidCells);
});
pack.burgs.forEach(burg => {
if (!burg.i || burg.removed) return;
if (burg.port < 0) {
ERROR && console.error("Data Integrity Check. Burg", burg.i, "has invalid port value", burg.port);
burg.port = 0;
}
if (burg.cell >= cells.i.length) {
ERROR && console.error("Data Integrity Check. Burg", burg.i, "is linked to invalid cell", burg.cell);
burg.cell = findCell(burg.x, burg.y);
cells.i.filter(i => cells.burg[i] === burg.i).forEach(i => (cells.burg[i] = 0));
cells.burg[burg.cell] = burg.i;
}
if (burg.state && !pack.states[burg.state]) {
ERROR && console.error("Data Integrity Check. Burg", burg.i, "is linked to invalid state", burg.state);
burg.state = 0;
}
if (burg.state === undefined) {
ERROR && console.error("Data Integrity Check. Burg", burg.i, "has no state data");
burg.state = 0;
}
});
pack.provinces.forEach(p => {
if (!p.i || p.removed) return;
if (pack.states[p.state] && !pack.states[p.state].removed) return;
ERROR && console.error("Data Integrity Check. Province", p.i, "is linked to removed state", p.state);
p.removed = true; // remove incorrect province
});
{
const markerIds = [];
let nextId = last(pack.markers)?.i + 1 || 0;
pack.markers.forEach(marker => {
if (markerIds[marker.i]) {
ERROR && console.error("Data Integrity Check. Marker", marker.i, "has non-unique id. Changing to", nextId);
const domElements = document.querySelectorAll("#marker" + marker.i);
if (domElements[1]) domElements[1].id = "marker" + nextId; // rename 2nd dom element
const noteElements = notes.filter(note => note.id === "marker" + marker.i);
if (noteElements[1]) noteElements[1].id = "marker" + nextId; // rename 2nd note
marker.i = nextId;
nextId += 1;
} else {
markerIds[marker.i] = true;
}
});
// sort markers by index
pack.markers.sort((a, b) => a.i - b.i);
}
})();
changeMapSize();
// remove href from emblems, to trigger rendering on load
emblems.selectAll("use").attr("href", null);
// draw data layers (no kept in svg)
if (rulers && layerIsOn("toggleRulers")) rulers.draw();
if (layerIsOn("toggleGrid")) drawGrid();
// set options
yearInput.value = options.year;
eraInput.value = options.era;
shapeRendering.value = viewbox.attr("shape-rendering") || "geometricPrecision";
restoreDefaultEvents();
focusOn(); // based on searchParams focus on point, cell or burg
invokeActiveZooming();
WARN && console.warn(`TOTAL: ${rn((performance.now() - uploadMap.timeStart) / 1000, 2)}s`);
showStatistics();
INFO && console.groupEnd("Loaded Map " + seed);
tip("Map is successfully loaded", true, "success", 7000);
} catch (error) {
ERROR && console.error(error);
clearMainTip();
alertMessage.innerHTML = /* html */ `An error is occured on map loading. Select a different file to load, <br />generate a new random map or cancel the loading
<p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Loading error",
maxWidth: "50em",
buttons: {
"Select file": function () {
$(this).dialog("close");
mapToLoad.click();
},
"New map": function () {
$(this).dialog("close");
regenerateMap("loading error");
},
Cancel: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
}

202
src/modules/io/save.js Normal file
View file

@ -0,0 +1,202 @@
import {tip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {ldb} from "/src/scripts/indexedDB";
import {ra} from "/src/utils/probabilityUtils";
// functions to save project as .map file
// prepare map data for saving
function getMapData() {
TIME && console.time("createMapData");
const date = new Date();
const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator";
const params = [version, license, dateString, seed, graphWidth, graphHeight, mapId].join("|");
const settings = [
distanceUnitInput.value,
distanceScaleInput.value,
areaUnit.value,
heightUnit.value,
heightExponentInput.value,
temperatureScale.value,
barSizeInput.value,
barLabel.value,
barBackOpacity.value,
barBackColor.value,
barPosX.value,
barPosY.value,
populationRate,
urbanization,
mapSizeOutput.value,
latitudeOutput.value,
temperatureEquatorOutput.value,
temperaturePoleOutput.value,
precOutput.value,
JSON.stringify(options),
mapName.value,
+hideLabels.checked,
stylePreset.value,
+rescaleLabels.checked,
urbanDensity
].join("|");
const coords = JSON.stringify(mapCoordinates);
const biomes = [biomesData.color, biomesData.habitability, biomesData.name].join("|");
const notesData = JSON.stringify(notes);
const rulersString = rulers.toString();
const fonts = JSON.stringify(getUsedFonts(svg.node()));
// save svg
const cloneEl = document.getElementById("map").cloneNode(true);
// reset transform values to default
cloneEl.setAttribute("width", graphWidth);
cloneEl.setAttribute("height", graphHeight);
cloneEl.querySelector("#viewbox").removeAttribute("transform");
cloneEl.querySelector("#ruler").innerHTML = ""; // always remove rulers
const serializedSVG = new XMLSerializer().serializeToString(cloneEl);
const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid;
const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired});
const packFeatures = JSON.stringify(pack.features);
const cultures = JSON.stringify(pack.cultures);
const states = JSON.stringify(pack.states);
const burgs = JSON.stringify(pack.burgs);
const religions = JSON.stringify(pack.religions);
const provinces = JSON.stringify(pack.provinces);
const rivers = JSON.stringify(pack.rivers);
const markers = JSON.stringify(pack.markers);
// store name array only if not the same as default
const defaultNB = Names.getNameBases();
const namesData = nameBases
.map((b, i) => {
const names = defaultNB[i] && defaultNB[i].b === b.b ? "" : b.b;
return `${b.name}|${b.min}|${b.max}|${b.d}|${b.m}|${names}`;
})
.join("/");
// round population to save space
const pop = Array.from(pack.cells.pop).map(p => rn(p, 4));
// data format as below
const mapData = [
params,
settings,
coords,
biomes,
notesData,
serializedSVG,
gridGeneral,
grid.cells.h,
grid.cells.prec,
grid.cells.f,
grid.cells.t,
grid.cells.temp,
packFeatures,
cultures,
states,
burgs,
pack.cells.biome,
pack.cells.burg,
pack.cells.conf,
pack.cells.culture,
pack.cells.fl,
pop,
pack.cells.r,
pack.cells.road,
pack.cells.s,
pack.cells.state,
pack.cells.religion,
pack.cells.province,
pack.cells.crossroad,
religions,
provinces,
namesData,
rivers,
rulersString,
fonts,
markers
].join("\r\n");
TIME && console.timeEnd("createMapData");
return mapData;
}
// Download .map file
function dowloadMap() {
if (customization)
return tip("Map cannot be saved when edit mode is active, please exit the mode and retry", false, "error");
closeDialogs("#alert");
const mapData = getMapData();
const blob = new Blob([mapData], {type: "text/plain"});
const URL = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = getFileName() + ".map";
link.href = URL;
link.click();
tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
window.URL.revokeObjectURL(URL);
}
async function saveToDropbox() {
if (customization)
return tip("Map cannot be saved when edit mode is active, please exit the mode and retry", false, "error");
closeDialogs("#alert");
const mapData = getMapData();
const filename = getFileName() + ".map";
try {
await Cloud.providers.dropbox.save(filename, mapData);
tip("Map is saved to your Dropbox", true, "success", 8000);
} catch (msg) {
ERROR && console.error(msg);
tip("Cannot save .map to your Dropbox", true, "error", 8000);
}
}
function quickSave() {
if (customization)
return tip("Map cannot be saved when edit mode is active, please exit the mode and retry", false, "error");
const mapData = getMapData();
const blob = new Blob([mapData], {type: "text/plain"});
if (blob) ldb.set("lastMap", blob); // auto-save map
tip("Map is saved to browser memory. Please also save as .map file to secure progress", true, "success", 2000);
}
const saveReminder = function () {
if (localStorage.getItem("noReminder")) return;
const message = [
"Please don't forget to save your work as a .map file",
"Please remember to save work as a .map file",
"Saving in .map format will ensure your data won't be lost in case of issues",
"Safety is number one priority. Please save the map",
"Don't forget to save your map on a regular basis!",
"Just a gentle reminder for you to save the map",
"Please don't forget to save your progress (saving as .map is the best option)",
"Don't want to be reminded about need to save? Press CTRL+Q"
];
const interval = 15 * 60 * 1000; // remind every 15 minutes
saveReminder.reminder = setInterval(() => {
if (customization) return;
tip(ra(message), true, "warn", 2500);
}, interval);
saveReminder.status = 1;
};
saveReminder();
function toggleSaveReminder() {
if (saveReminder.status) {
tip("Save reminder is turned off. Press CTRL+Q again to re-initiate", true, "warn", 2000);
clearInterval(saveReminder.reminder);
localStorage.setItem("noReminder", true);
saveReminder.status = 0;
} else {
tip("Save reminder is turned on. Press CTRL+Q to turn off", true, "warn", 2000);
localStorage.removeItem("noReminder");
saveReminder();
}
}

153
src/modules/lakes.js Normal file
View file

@ -0,0 +1,153 @@
import {rn} from "/src/utils/numberUtils";
window.Lakes = (function () {
const setClimateData = function (h) {
const cells = pack.cells;
const lakeOutCells = new Uint16Array(cells.i.length);
pack.features.forEach(f => {
if (f.type !== "lake") return;
// default flux: sum of precipitation around lake
f.flux = f.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0);
// temperature and evaporation to detect closed lakes
f.temp =
f.cells < 6
? grid.cells.temp[cells.g[f.firstCell]]
: rn(d3.mean(f.shoreline.map(c => grid.cells.temp[cells.g[c]])), 1);
const height = (f.height - 18) ** heightExponentInput.value; // height in meters
const evaporation = ((700 * (f.temp + 0.006 * height)) / 50 + 75) / (80 - f.temp); // based on Penman formula, [1-11]
f.evaporation = rn(evaporation * f.cells);
// no outlet for lakes in depressed areas
if (f.closed) return;
// lake outlet cell
f.outCell = f.shoreline[d3.scan(f.shoreline, (a, b) => h[a] - h[b])];
lakeOutCells[f.outCell] = f.i;
});
return lakeOutCells;
};
// get array of land cells aroound lake
const getShoreline = function (lake) {
const uniqueCells = new Set();
lake.vertices.forEach(v => pack.vertices.c[v].forEach(c => pack.cells.h[c] >= 20 && uniqueCells.add(c)));
lake.shoreline = [...uniqueCells];
};
const prepareLakeData = h => {
const cells = pack.cells;
const ELEVATION_LIMIT = +document.getElementById("lakeElevationLimitOutput").value;
pack.features.forEach(f => {
if (f.type !== "lake") return;
delete f.flux;
delete f.inlets;
delete f.outlet;
delete f.height;
delete f.closed;
!f.shoreline && Lakes.getShoreline(f);
// lake surface height is as lowest land cells around
const min = f.shoreline.sort((a, b) => h[a] - h[b])[0];
f.height = h[min] - 0.1;
// check if lake can be open (not in deep depression)
if (ELEVATION_LIMIT === 80) {
f.closed = false;
return;
}
let deep = true;
const threshold = f.height + ELEVATION_LIMIT;
const queue = [min];
const checked = [];
checked[min] = true;
// check if elevated lake can potentially pour to another water body
while (deep && queue.length) {
const q = queue.pop();
for (const n of cells.c[q]) {
if (checked[n]) continue;
if (h[n] >= threshold) continue;
if (h[n] < 20) {
const nFeature = pack.features[cells.f[n]];
if (nFeature.type === "ocean" || f.height > nFeature.height) {
deep = false;
break;
}
}
checked[n] = true;
queue.push(n);
}
}
f.closed = deep;
});
};
const cleanupLakeData = function () {
for (const feature of pack.features) {
if (feature.type !== "lake") continue;
delete feature.river;
delete feature.enteringFlux;
delete feature.outCell;
delete feature.closed;
feature.height = rn(feature.height, 3);
const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r));
if (!inlets || !inlets.length) delete feature.inlets;
else feature.inlets = inlets;
const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet);
if (!outlet) delete feature.outlet;
}
};
const defineGroup = function () {
for (const feature of pack.features) {
if (feature.type !== "lake") continue;
const lakeEl = lakes.select(`[data-f="${feature.i}"]`).node();
if (!lakeEl) continue;
feature.group = getGroup(feature);
document.getElementById(feature.group).appendChild(lakeEl);
}
};
const generateName = function () {
Math.random = aleaPRNG(seed);
for (const feature of pack.features) {
if (feature.type !== "lake") continue;
feature.name = getName(feature);
}
};
const getName = function (feature) {
const landCell = pack.cells.c[feature.firstCell].find(c => pack.cells.h[c] >= 20);
const culture = pack.cells.culture[landCell];
return Names.getCulture(culture);
};
function getGroup(feature) {
if (feature.temp < -3) return "frozen";
if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava";
if (!feature.inlets && !feature.outlet) {
if (feature.evaporation > feature.flux * 4) return "dry";
if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole";
}
if (!feature.outlet && feature.evaporation > feature.flux) return "salt";
return "freshwater";
}
return {setClimateData, cleanupLakeData, prepareLakeData, defineGroup, generateName, getName, getShoreline};
})();

View file

@ -1,7 +1,7 @@
import {rn} from "../utils/numberUtils";
import {parseTransform} from "/src/utils/stringUtils";
export function drawLegend(name: string, data: unknown[]) {
export function drawLegend(name, data) {
legend.selectAll("*").remove(); // fully redraw every time
legend.attr("data", data.join("|")); // store data

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,522 @@
import {TIME} from "/src/config/logging";
import {rn, minmax} from "/src/utils/numberUtils";
import {rand, gauss, ra} from "/src/utils/probabilityUtils";
import {si} from "/src/utils/unitUtils";
import {nth} from "/src/utils/languageUtils";
window.Military = (function () {
const generate = function () {
TIME && console.time("generateMilitaryForces");
const {cells, states} = pack;
const {p} = cells;
const valid = states.filter(s => s.i && !s.removed); // valid states
if (!options.military) options.military = getDefaultOptions();
const expn = d3.sum(valid.map(s => s.expansionism)); // total expansion
const area = d3.sum(valid.map(s => s.area)); // total area
const rate = {
x: 0,
Ally: -0.2,
Friendly: -0.1,
Neutral: 0,
Suspicion: 0.1,
Enemy: 1,
Unknown: 0,
Rival: 0.5,
Vassal: 0.5,
Suzerain: -0.5
};
const stateModifier = {
melee: {Nomadic: 0.5, Highland: 1.2, Lake: 1, Naval: 0.7, Hunting: 1.2, River: 1.1},
ranged: {Nomadic: 0.9, Highland: 1.3, Lake: 1, Naval: 0.8, Hunting: 2, River: 0.8},
mounted: {Nomadic: 2.3, Highland: 0.6, Lake: 0.7, Naval: 0.3, Hunting: 0.7, River: 0.8},
machinery: {Nomadic: 0.8, Highland: 1.4, Lake: 1.1, Naval: 1.4, Hunting: 0.4, River: 1.1},
naval: {Nomadic: 0.5, Highland: 0.5, Lake: 1.2, Naval: 1.8, Hunting: 0.7, River: 1.2},
armored: {Nomadic: 1, Highland: 0.5, Lake: 1, Naval: 1, Hunting: 0.7, River: 1.1},
aviation: {Nomadic: 0.5, Highland: 0.5, Lake: 1.2, Naval: 1.2, Hunting: 0.6, River: 1.2},
magical: {Nomadic: 1, Highland: 2, Lake: 1, Naval: 1, Hunting: 1, River: 1}
};
const cellTypeModifier = {
nomadic: {
melee: 0.2,
ranged: 0.5,
mounted: 3,
machinery: 0.4,
naval: 0.3,
armored: 1.6,
aviation: 1,
magical: 0.5
},
wetland: {
melee: 0.8,
ranged: 2,
mounted: 0.3,
machinery: 1.2,
naval: 1.0,
armored: 0.2,
aviation: 0.5,
magical: 0.5
},
highland: {
melee: 1.2,
ranged: 1.6,
mounted: 0.3,
machinery: 3,
naval: 1.0,
armored: 0.8,
aviation: 0.3,
magical: 2
}
};
const burgTypeModifier = {
nomadic: {
melee: 0.3,
ranged: 0.8,
mounted: 3,
machinery: 0.4,
naval: 1.0,
armored: 1.6,
aviation: 1,
magical: 0.5
},
wetland: {
melee: 1,
ranged: 1.6,
mounted: 0.2,
machinery: 1.2,
naval: 1.0,
armored: 0.2,
aviation: 0.5,
magical: 0.5
},
highland: {melee: 1.2, ranged: 2, mounted: 0.3, machinery: 3, naval: 1.0, armored: 0.8, aviation: 0.3, magical: 2}
};
valid.forEach(s => {
s.temp = {};
const d = s.diplomacy;
const expansionRate = minmax(s.expansionism / expn / (s.area / area), 0.25, 4); // how much state expansionism is realized
const diplomacyRate = d.some(d => d === "Enemy")
? 1
: d.some(d => d === "Rival")
? 0.8
: d.some(d => d === "Suspicion")
? 0.5
: 0.1; // peacefulness
const neighborsRateRaw = s.neighbors
.map(n => (n ? pack.states[n].diplomacy[s.i] : "Suspicion"))
.reduce((s, r) => (s += rate[r]), 0.5);
const neighborsRate = minmax(neighborsRateRaw, 0.3, 3); // neighbors rate
s.alert = minmax(rn(expansionRate * diplomacyRate * neighborsRate, 2), 0.1, 5); // alert rate (area modifier)
s.temp.platoons = [];
// apply overall state modifiers for unit types based on state features
for (const unit of options.military) {
if (!stateModifier[unit.type]) continue;
let modifier = stateModifier[unit.type][s.type] || 1;
if (unit.type === "mounted" && s.formName.includes("Horde")) modifier *= 2;
else if (unit.type === "naval" && s.form === "Republic") modifier *= 1.2;
s.temp[unit.name] = modifier * s.alert;
}
});
const getType = cell => {
if ([1, 2, 3, 4].includes(cells.biome[cell])) return "nomadic";
if ([7, 8, 9, 12].includes(cells.biome[cell])) return "wetland";
if (cells.h[cell] >= 70) return "highland";
return "generic";
};
function passUnitLimits(unit, biome, state, culture, religion) {
if (unit.biomes && !unit.biomes.includes(biome)) return false;
if (unit.states && !unit.states.includes(state)) return false;
if (unit.cultures && !unit.cultures.includes(culture)) return false;
if (unit.religions && !unit.religions.includes(religion)) return false;
return true;
}
// rural cells
for (const i of cells.i) {
if (!cells.pop[i]) continue;
const biome = cells.biome[i];
const state = cells.state[i];
const culture = cells.culture[i];
const religion = cells.religion[i];
const stateObj = states[state];
if (!state || stateObj.removed) continue;
let modifier = cells.pop[i] / 100; // basic rural army in percentages
if (culture !== stateObj.culture) modifier = stateObj.form === "Union" ? modifier / 1.2 : modifier / 2; // non-dominant culture
if (religion !== cells.religion[stateObj.center])
modifier = stateObj.form === "Theocracy" ? modifier / 2.2 : modifier / 1.4; // non-dominant religion
if (cells.f[i] !== cells.f[stateObj.center])
modifier = stateObj.type === "Naval" ? modifier / 1.2 : modifier / 1.8; // different landmass
const type = getType(i);
for (const unit of options.military) {
const perc = +unit.rural;
if (isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) continue;
if (!passUnitLimits(unit, biome, state, culture, religion)) continue;
if (unit.type === "naval" && !cells.haven[i]) continue; // only near-ocean cells create naval units
const cellTypeMod = type === "generic" ? 1 : cellTypeModifier[type][unit.type]; // cell specific modifier
const army = modifier * perc * cellTypeMod; // rural cell army
const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops
if (!total) continue;
let [x, y] = p[i];
let n = 0;
// place naval units to sea
if (unit.type === "naval") {
const haven = cells.haven[i];
[x, y] = p[haven];
n = 1;
}
stateObj.temp.platoons.push({
cell: i,
a: total,
t: total,
x,
y,
u: unit.name,
n,
s: unit.separate,
type: unit.type
});
}
}
// burgs
for (const b of pack.burgs) {
if (!b.i || b.removed || !b.state || !b.population) continue;
const biome = cells.biome[b.cell];
const state = b.state;
const culture = b.culture;
const religion = cells.religion[b.cell];
const stateObj = states[state];
let m = (b.population * urbanization) / 100; // basic urban army in percentages
if (b.capital) m *= 1.2; // capital has household troops
if (culture !== stateObj.culture) m = stateObj.form === "Union" ? m / 1.2 : m / 2; // non-dominant culture
if (religion !== cells.religion[stateObj.center]) m = stateObj.form === "Theocracy" ? m / 2.2 : m / 1.4; // non-dominant religion
if (cells.f[b.cell] !== cells.f[stateObj.center]) m = stateObj.type === "Naval" ? m / 1.2 : m / 1.8; // different landmass
const type = getType(b.cell);
for (const unit of options.military) {
const perc = +unit.urban;
if (isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) continue;
if (!passUnitLimits(unit, biome, state, culture, religion)) continue;
if (unit.type === "naval" && (!b.port || !cells.haven[b.cell])) continue; // only ports create naval units
const mod = type === "generic" ? 1 : burgTypeModifier[type][unit.type]; // cell specific modifier
const army = m * perc * mod; // urban cell army
const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops
if (!total) continue;
let [x, y] = p[b.cell];
let n = 0;
// place naval to sea
if (unit.type === "naval") {
const haven = cells.haven[b.cell];
[x, y] = p[haven];
n = 1;
}
stateObj.temp.platoons.push({
cell: b.cell,
a: total,
t: total,
x,
y,
u: unit.name,
n,
s: unit.separate,
type: unit.type
});
}
}
const expected = 3 * populationRate; // expected regiment size
const mergeable = (n0, n1) => (!n0.s && !n1.s) || n0.u === n1.u; // check if regiments can be merged
// get regiments for each state
valid.forEach(s => {
s.military = createRegiments(s.temp.platoons, s);
delete s.temp; // do not store temp data
});
redraw();
function createRegiments(nodes, s) {
if (!nodes.length) return [];
nodes.sort((a, b) => a.a - b.a); // form regiments in cells with most troops
const tree = d3.quadtree(
nodes,
d => d.x,
d => d.y
);
nodes.forEach(node => {
tree.remove(node);
const overlap = tree.find(node.x, node.y, 20);
if (overlap && overlap.t && mergeable(node, overlap)) {
merge(node, overlap);
return;
}
if (node.t > expected) return;
const r = (expected - node.t) / (node.s ? 40 : 20); // search radius
const candidates = tree.findAll(node.x, node.y, r);
for (const c of candidates) {
if (c.t < expected && mergeable(node, c)) {
merge(node, c);
break;
}
}
});
// add n0 to n1's ultimate parent
function merge(n0, n1) {
if (!n1.childen) n1.childen = [n0];
else n1.childen.push(n0);
if (n0.childen) n0.childen.forEach(n => n1.childen.push(n));
n1.t += n0.t;
n0.t = 0;
}
// parse regiments data
const regiments = nodes
.filter(n => n.t)
.sort((a, b) => b.t - a.t)
.map((r, i) => {
const u = {};
u[r.u] = r.a;
(r.childen || []).forEach(n => (u[n.u] = u[n.u] ? (u[n.u] += n.a) : n.a));
return {i, a: r.t, cell: r.cell, x: r.x, y: r.y, bx: r.x, by: r.y, u, n: r.n, name, state: s.i};
});
// generate name for regiments
regiments.forEach(r => {
r.name = getName(r, regiments);
r.icon = getEmblem(r);
generateNote(r, s);
});
return regiments;
}
TIME && console.timeEnd("generateMilitaryForces");
};
function redraw() {
const validStates = pack.states.filter(s => s.i && !s.removed);
armies.selectAll("g > g").each(function () {
const index = notes.findIndex(n => n.id === this.id);
if (index != -1) notes.splice(index, 1);
});
armies.selectAll("g").remove();
validStates.forEach(s => drawRegiments(s.military, s.i));
}
const getDefaultOptions = function () {
return [
{icon: "⚔️", name: "infantry", rural: 0.25, urban: 0.2, crew: 1, power: 1, type: "melee", separate: 0},
{icon: "🏹", name: "archers", rural: 0.12, urban: 0.2, crew: 1, power: 1, type: "ranged", separate: 0},
{icon: "🐴", name: "cavalry", rural: 0.12, urban: 0.03, crew: 2, power: 2, type: "mounted", separate: 0},
{icon: "💣", name: "artillery", rural: 0, urban: 0.03, crew: 8, power: 12, type: "machinery", separate: 0},
{icon: "🌊", name: "fleet", rural: 0, urban: 0.015, crew: 100, power: 50, type: "naval", separate: 1}
];
};
const drawRegiments = function (regiments, s) {
const size = +armies.attr("box-size");
const w = d => (d.n ? size * 4 : size * 6);
const h = size * 2;
const x = d => rn(d.x - w(d) / 2, 2);
const y = d => rn(d.y - size, 2);
const baseColor = pack.states[s].color[0] === "#" ? pack.states[s].color : "#999";
const darkerColor = d3.color(baseColor).darker().hex();
const army = armies
.append("g")
.attr("id", "army" + s)
.attr("fill", baseColor);
const g = army
.selectAll("g")
.data(regiments)
.enter()
.append("g")
.attr("id", d => "regiment" + s + "-" + d.i)
.attr("data-name", d => d.name)
.attr("data-state", s)
.attr("data-id", d => d.i);
g.append("rect")
.attr("x", d => x(d))
.attr("y", d => y(d))
.attr("width", d => w(d))
.attr("height", h);
g.append("text")
.attr("x", d => d.x)
.attr("y", d => d.y)
.text(d => getTotal(d));
g.append("rect")
.attr("fill", darkerColor)
.attr("x", d => x(d) - h)
.attr("y", d => y(d))
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("x", d => x(d) - size)
.attr("y", d => d.y)
.text(d => d.icon);
};
const drawRegiment = function (reg, s) {
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = rn(reg.x - w / 2, 2);
const y1 = rn(reg.y - size, 2);
let army = armies.select("g#army" + s);
if (!army.size()) {
const baseColor = pack.states[s].color[0] === "#" ? pack.states[s].color : "#999";
army = armies
.append("g")
.attr("id", "army" + s)
.attr("fill", baseColor);
}
const darkerColor = d3.color(army.attr("fill")).darker().hex();
const g = army
.append("g")
.attr("id", "regiment" + s + "-" + reg.i)
.attr("data-name", reg.name)
.attr("data-state", s)
.attr("data-id", reg.i);
g.append("rect").attr("x", x1).attr("y", y1).attr("width", w).attr("height", h);
g.append("text").attr("x", reg.x).attr("y", reg.y).text(getTotal(reg));
g.append("rect")
.attr("fill", darkerColor)
.attr("x", x1 - h)
.attr("y", y1)
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("x", x1 - size)
.attr("y", reg.y)
.text(reg.icon);
};
// move one regiment to another
const moveRegiment = function (reg, x, y) {
const el = armies.select("g#army" + reg.state).select("g#regiment" + reg.state + "-" + reg.i);
if (!el.size()) return;
const duration = Math.hypot(reg.x - x, reg.y - y) * 8;
reg.x = x;
reg.y = y;
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = x => rn(x - w / 2, 2);
const y1 = y => rn(y - size, 2);
const move = d3.transition().duration(duration).ease(d3.easeSinInOut);
el.select("rect").transition(move).attr("x", x1(x)).attr("y", y1(y));
el.select("text").transition(move).attr("x", x).attr("y", y);
el.selectAll("rect:nth-of-type(2)")
.transition(move)
.attr("x", x1(x) - h)
.attr("y", y1(y));
el.select(".regimentIcon")
.transition(move)
.attr("x", x1(x) - size)
.attr("y", y);
};
// utilize si function to make regiment total text fit regiment box
const getTotal = reg => (reg.a > (reg.n ? 999 : 99999) ? si(reg.a) : reg.a);
const getName = function (r, regiments) {
const cells = pack.cells;
const proper = r.n
? null
: cells.province[r.cell] && pack.provinces[cells.province[r.cell]]
? pack.provinces[cells.province[r.cell]].name
: cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]]
? pack.burgs[cells.burg[r.cell]].name
: null;
const number = nth(regiments.filter(reg => reg.n === r.n && reg.i < r.i).length + 1);
const form = r.n ? "Fleet" : "Regiment";
return `${number}${proper ? ` (${proper}) ` : ` `}${form}`;
};
// get default regiment emblem
const getEmblem = function (r) {
if (!r.n && !Object.values(r.u).length) return "🔰"; // "Newbie" regiment without troops
if (
!r.n &&
pack.states[r.state].form === "Monarchy" &&
pack.cells.burg[r.cell] &&
pack.burgs[pack.cells.burg[r.cell]].capital
)
return "👑"; // "Royal" regiment based in capital
const mainUnit = Object.entries(r.u).sort((a, b) => b[1] - a[1])[0][0]; // unit with more troops in regiment
const unit = options.military.find(u => u.name === mainUnit);
return unit.icon;
};
const generateNote = function (r, s) {
const cells = pack.cells;
const base =
cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]]
? pack.burgs[cells.burg[r.cell]].name
: cells.province[r.cell] && pack.provinces[cells.province[r.cell]]
? pack.provinces[cells.province[r.cell]].fullName
: null;
const station = base ? `${r.name} is ${r.n ? "based" : "stationed"} in ${base}. ` : "";
const composition = r.a
? Object.keys(r.u)
.map(t => `${t}: ${r.u[t]}`)
.join("\r\n")
: null;
const troops = composition
? `\r\n\r\nRegiment composition in ${options.year} ${options.eraShort}:\r\n${composition}.`
: "";
const campaign = s.campaigns ? ra(s.campaigns) : null;
const year = campaign ? rand(campaign.start, campaign.end) : gauss(options.year - 100, 150, 1, options.year - 6);
const conflict = campaign ? ` during the ${campaign.name}` : "";
const legend = `Regiment was formed in ${year} ${options.era}${conflict}. ${station}${troops}`;
notes.push({id: `regiment${s.i}-${r.i}`, name: `${r.icon} ${r.name}`, legend});
};
return {
generate,
redraw,
getDefaultOptions,
getName,
generateNote,
drawRegiments,
drawRegiment,
moveRegiment,
getTotal,
getEmblem
};
})();

View file

@ -0,0 +1,329 @@
import {last} from "/src/utils/arrayUtils";
import {locked} from "/src/scripts/options/lock";
import {tip} from "/src/scripts/tooltips";
import {rand, P, ra} from "/src/utils/probabilityUtils";
import {capitalize} from "/src/utils/stringUtils";
import {vowel} from "/src/utils/languageUtils";
window.Names = (function () {
let chains = [];
// calculate Markov chain for a namesbase
const calculateChain = function (string) {
const chain = [];
const array = string.split(",");
for (const n of array) {
let name = n.trim().toLowerCase();
const basic = !/[^\u0000-\u007f]/.test(name); // basic chars and English rules can be applied
// split word into pseudo-syllables
for (let i = -1, syllable = ""; i < name.length; i += syllable.length || 1, syllable = "") {
let prev = name[i] || ""; // pre-onset letter
let v = 0; // 0 if no vowels in syllable
for (let c = i + 1; name[c] && syllable.length < 5; c++) {
const that = name[c],
next = name[c + 1]; // next char
syllable += that;
if (syllable === " " || syllable === "-") break; // syllable starts with space or hyphen
if (!next || next === " " || next === "-") break; // no need to check
if (vowel(that)) v = 1; // check if letter is vowel
// do not split some diphthongs
if (that === "y" && next === "e") continue; // 'ye'
if (basic) {
// English-like
if (that === "o" && next === "o") continue; // 'oo'
if (that === "e" && next === "e") continue; // 'ee'
if (that === "a" && next === "e") continue; // 'ae'
if (that === "c" && next === "h") continue; // 'ch'
}
if (vowel(that) === next) break; // two same vowels in a row
if (v && vowel(name[c + 2])) break; // syllable has vowel and additional vowel is expected soon
}
if (chain[prev] === undefined) chain[prev] = [];
chain[prev].push(syllable);
}
}
return chain;
};
// update chain for specific base
const updateChain = i => (chains[i] = nameBases[i] || nameBases[i].b ? calculateChain(nameBases[i].b) : null);
// update chains for all used bases
const clearChains = () => (chains = []);
// generate name using Markov's chain
const getBase = function (base, min, max, dupl) {
if (base === undefined) {
ERROR && console.error("Please define a base");
return;
}
if (!chains[base]) updateChain(base);
const data = chains[base];
if (!data || data[""] === undefined) {
tip("Namesbase " + base + " is incorrect. Please check in namesbase editor", false, "error");
ERROR && console.error("Namebase " + base + " is incorrect!");
return "ERROR";
}
if (!min) min = nameBases[base].min;
if (!max) max = nameBases[base].max;
if (dupl !== "") dupl = nameBases[base].d;
let v = data[""],
cur = ra(v),
w = "";
for (let i = 0; i < 20; i++) {
if (cur === "") {
// end of word
if (w.length < min) {
cur = "";
w = "";
v = data[""];
} else break;
} else {
if (w.length + cur.length > max) {
// word too long
if (w.length < min) w += cur;
break;
} else v = data[last(cur)] || data[""];
}
w += cur;
cur = ra(v);
}
// parse word to get a final name
const l = last(w); // last letter
if (l === "'" || l === " " || l === "-") w = w.slice(0, -1); // not allow some characters at the end
let name = [...w].reduce(function (r, c, i, d) {
if (c === d[i + 1] && !dupl.includes(c)) return r; // duplication is not allowed
if (!r.length) return c.toUpperCase();
if (r.slice(-1) === "-" && c === " ") return r; // remove space after hyphen
if (r.slice(-1) === " ") return r + c.toUpperCase(); // capitalize letter after space
if (r.slice(-1) === "-") return r + c.toUpperCase(); // capitalize letter after hyphen
if (c === "a" && d[i + 1] === "e") return r; // "ae" => "e"
if (i + 2 < d.length && c === d[i + 1] && c === d[i + 2]) return r; // remove three same letters in a row
return r + c;
}, "");
// join the word if any part has only 1 letter
if (name.split(" ").some(part => part.length < 2))
name = name
.split(" ")
.map((p, i) => (i ? p.toLowerCase() : p))
.join("");
if (name.length < 2) {
ERROR && console.error("Name is too short! Random name will be selected");
name = ra(nameBases[base].b.split(","));
}
return name;
};
// generate name for culture
const getCulture = function (culture, min, max, dupl) {
if (culture === undefined) return ERROR && console.error("Please define a culture");
const base = pack.cultures[culture].base;
return getBase(base, min, max, dupl);
};
// generate short name for culture
const getCultureShort = function (culture) {
if (culture === undefined) return ERROR && console.error("Please define a culture");
return getBaseShort(pack.cultures[culture].base);
};
// generate short name for base
const getBaseShort = function (base) {
if (nameBases[base] === undefined) {
tip(
`Namebase ${base} does not exist. Please upload custom namebases of change the base in Cultures Editor`,
false,
"error"
);
base = 1;
}
const min = nameBases[base].min - 1;
const max = Math.max(nameBases[base].max - 2, min);
return getBase(base, min, max, "", 0);
};
// generate state name based on capital or random name and culture-specific suffix
const getState = function (name, culture, base) {
if (name === undefined) return ERROR && console.error("Please define a base name");
if (culture === undefined && base === undefined) return ERROR && console.error("Please define a culture");
if (base === undefined) base = pack.cultures[culture].base;
// exclude endings inappropriate for states name
if (name.includes(" ")) name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names
if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0, -4); // remove -berg for any
if (name.length > 5 && name.slice(-3) === "ton") name = name.slice(0, -3); // remove -ton for any
if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) name = name.slice(0, -2);
// remove -sk/-ev/-ov for Ruthenian
else if (base === 12) return vowel(name.slice(-1)) ? name : name + "u";
// Japanese ends on any vowel or -u
else if (base === 18 && P(0.4))
name = vowel(name.slice(0, 1).toLowerCase()) ? "Al" + name.toLowerCase() : "Al " + name; // Arabic starts with -Al
// no suffix for fantasy bases
if (base > 32 && base < 42) return name;
// define if suffix should be used
if (name.length > 3 && vowel(name.slice(-1))) {
if (vowel(name.slice(-2, -1)) && P(0.85)) name = name.slice(0, -2);
// 85% for vv
else if (P(0.7)) name = name.slice(0, -1);
// ~60% for cv
else return name;
} else if (P(0.4)) return name; // 60% for cc and vc
// define suffix
let suffix = "ia"; // standard suffix
const rnd = Math.random(),
l = name.length;
if (base === 3 && rnd < 0.03 && l < 7) suffix = "terra";
// Italian
else if (base === 4 && rnd < 0.03 && l < 7) suffix = "terra";
// Spanish
else if (base === 13 && rnd < 0.03 && l < 7) suffix = "terra";
// Portuguese
else if (base === 2 && rnd < 0.03 && l < 7) suffix = "terre";
// French
else if (base === 0 && rnd < 0.5 && l < 7) suffix = "land";
// German
else if (base === 1 && rnd < 0.4 && l < 7) suffix = "land";
// English
else if (base === 6 && rnd < 0.3 && l < 7) suffix = "land";
// Nordic
else if (base === 32 && rnd < 0.1 && l < 7) suffix = "land";
// generic Human
else if (base === 7 && rnd < 0.1) suffix = "eia";
// Greek
else if (base === 9 && rnd < 0.35) suffix = "maa";
// Finnic
else if (base === 15 && rnd < 0.4 && l < 6) suffix = "orszag";
// Hungarian
else if (base === 16) suffix = rnd < 0.6 ? "stan" : "ya";
// Turkish
else if (base === 10) suffix = "guk";
// Korean
else if (base === 11) suffix = " Guo";
// Chinese
else if (base === 14) suffix = rnd < 0.5 && l < 6 ? "tlan" : "co";
// Nahuatl
else if (base === 17 && rnd < 0.8) suffix = "a";
// Berber
else if (base === 18 && rnd < 0.8) suffix = "a"; // Arabic
return validateSuffix(name, suffix);
};
function validateSuffix(name, suffix) {
if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it
const s1 = suffix.charAt(0);
if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter
if (vowel(s1) === vowel(name.slice(-1)) && vowel(s1) === vowel(name.slice(-2, -1))) name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st
if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter
return name + suffix;
}
// generato name for the map
const getMapName = function (force) {
if (!force && locked("mapName")) return;
if (force && locked("mapName")) unlock("mapName");
const base = P(0.7) ? 2 : P(0.5) ? rand(0, 6) : rand(0, 31);
if (!nameBases[base]) {
tip("Namebase is not found", false, "error");
return "";
}
const min = nameBases[base].min - 1;
const max = Math.max(nameBases[base].max - 3, min);
const baseName = getBase(base, min, max, "", 0);
const name = P(0.7) ? addSuffix(baseName) : baseName;
mapName.value = name;
};
function addSuffix(name) {
const suffix = P(0.8) ? "ia" : "land";
if (suffix === "ia" && name.length > 6) name = name.slice(0, -(name.length - 3));
else if (suffix === "land" && name.length > 6) name = name.slice(0, -(name.length - 5));
return validateSuffix(name, suffix);
}
const getNameBases = function () {
// name, min length, max length, letters to allow duplication, multi-word name rate [deprecated]
// prettier-ignore
return [
// real-world bases by Azgaar:
{name: "German", i: 0, min: 5, max: 12, d: "lt", m: 0, b: "Achern,Aichhalden,Aitern,Albbruck,Alpirsbach,Altensteig,Althengstett,Appenweier,Auggen,Wildbad,Badenen,Badenweiler,Baiersbronn,Ballrechten,Bellingen,Berghaupten,Bernau,Biberach,Biederbach,Binzen,Birkendorf,Birkenfeld,Bischweier,Blumberg,Bollen,Bollschweil,Bonndorf,Bosingen,Braunlingen,Breisach,Breisgau,Breitnau,Brigachtal,Buchenbach,Buggingen,Buhl,Buhlertal,Calw,Dachsberg,Dobel,Donaueschingen,Dornhan,Dornstetten,Dottingen,Dunningen,Durbach,Durrheim,Ebhausen,Ebringen,Efringen,Egenhausen,Ehrenkirchen,Ehrsberg,Eimeldingen,Eisenbach,Elzach,Elztal,Emmendingen,Endingen,Engelsbrand,Enz,Enzklosterle,Eschbronn,Ettenheim,Ettlingen,Feldberg,Fischerbach,Fischingen,Fluorn,Forbach,Freiamt,Freiburg,Freudenstadt,Friedenweiler,Friesenheim,Frohnd,Furtwangen,Gaggenau,Geisingen,Gengenbach,Gernsbach,Glatt,Glatten,Glottertal,Gorwihl,Gottenheim,Grafenhausen,Grenzach,Griesbach,Gutach,Gutenbach,Hag,Haiterbach,Hardt,Harmersbach,Hasel,Haslach,Hausach,Hausen,Hausern,Heitersheim,Herbolzheim,Herrenalb,Herrischried,Hinterzarten,Hochenschwand,Hofen,Hofstetten,Hohberg,Horb,Horben,Hornberg,Hufingen,Ibach,Ihringen,Inzlingen,Kandern,Kappel,Kappelrodeck,Karlsbad,Karlsruhe,Kehl,Keltern,Kippenheim,Kirchzarten,Konigsfeld,Krozingen,Kuppenheim,Kussaberg,Lahr,Lauchringen,Lauf,Laufenburg,Lautenbach,Lauterbach,Lenzkirch,Liebenzell,Loffenau,Loffingen,Lorrach,Lossburg,Mahlberg,Malsburg,Malsch,March,Marxzell,Marzell,Maulburg,Monchweiler,Muhlenbach,Mullheim,Munstertal,Murg,Nagold,Neubulach,Neuenburg,Neuhausen,Neuried,Neuweiler,Niedereschach,Nordrach,Oberharmersbach,Oberkirch,Oberndorf,Oberbach,Oberried,Oberwolfach,Offenburg,Ohlsbach,Oppenau,Ortenberg,otigheim,Ottenhofen,Ottersweier,Peterstal,Pfaffenweiler,Pfalzgrafenweiler,Pforzheim,Rastatt,Renchen,Rheinau,Rheinfelden,Rheinmunster,Rickenbach,Rippoldsau,Rohrdorf,Rottweil,Rummingen,Rust,Sackingen,Sasbach,Sasbachwalden,Schallbach,Schallstadt,Schapbach,Schenkenzell,Schiltach,Schliengen,Schluchsee,Schomberg,Schonach,Schonau,Schonenberg,Schonwald,Schopfheim,Schopfloch,Schramberg,Schuttertal,Schwenningen,Schworstadt,Seebach,Seelbach,Seewald,Sexau,Simmersfeld,Simonswald,Sinzheim,Solden,Staufen,Stegen,Steinach,Steinen,Steinmauern,Straubenhardt,Stuhlingen,Sulz,Sulzburg,Teinach,Tiefenbronn,Tiengen,Titisee,Todtmoos,Todtnau,Todtnauberg,Triberg,Tunau,Tuningen,uhlingen,Unterkirnach,Reichenbach,Utzenfeld,Villingen,Villingendorf,Vogtsburg,Vohrenbach,Waldachtal,Waldbronn,Waldkirch,Waldshut,Wehr,Weil,Weilheim,Weisenbach,Wembach,Wieden,Wiesental,Wildberg,Winzeln,Wittlingen,Wittnau,Wolfach,Wutach,Wutoschingen,Wyhlen,Zavelstein"},
{name: "English", i: 1, min: 6, max: 11, d: "", m: .1, b: "Abingdon,Albrighton,Alcester,Almondbury,Altrincham,Amersham,Andover,Appleby,Ashboume,Atherstone,Aveton,Axbridge,Aylesbury,Baldock,Bamburgh,Barton,Basingstoke,Berden,Bere,Berkeley,Berwick,Betley,Bideford,Bingley,Birmingham,Blandford,Blechingley,Bodmin,Bolton,Bootham,Boroughbridge,Boscastle,Bossinney,Bramber,Brampton,Brasted,Bretford,Bridgetown,Bridlington,Bromyard,Bruton,Buckingham,Bungay,Burton,Calne,Cambridge,Canterbury,Carlisle,Castleton,Caus,Charmouth,Chawleigh,Chichester,Chillington,Chinnor,Chipping,Chisbury,Cleobury,Clifford,Clifton,Clitheroe,Cockermouth,Coleshill,Combe,Congleton,Crafthole,Crediton,Cuddenbeck,Dalton,Darlington,Dodbrooke,Drax,Dudley,Dunstable,Dunster,Dunwich,Durham,Dymock,Exeter,Exning,Faringdon,Felton,Fenny,Finedon,Flookburgh,Fowey,Frampton,Gateshead,Gatton,Godmanchester,Grampound,Grantham,Guildford,Halesowen,Halton,Harbottle,Harlow,Hatfield,Hatherleigh,Haydon,Helston,Henley,Hertford,Heytesbury,Hinckley,Hitchin,Holme,Hornby,Horsham,Kendal,Kenilworth,Kilkhampton,Kineton,Kington,Kinver,Kirby,Knaresborough,Knutsford,Launceston,Leighton,Lewes,Linton,Louth,Luton,Lyme,Lympstone,Macclesfield,Madeley,Malborough,Maldon,Manchester,Manningtree,Marazion,Marlborough,Marshfield,Mere,Merryfield,Middlewich,Midhurst,Milborne,Mitford,Modbury,Montacute,Mousehole,Newbiggin,Newborough,Newbury,Newenden,Newent,Norham,Northleach,Noss,Oakham,Olney,Orford,Ormskirk,Oswestry,Padstow,Paignton,Penkneth,Penrith,Penzance,Pershore,Petersfield,Pevensey,Pickering,Pilton,Pontefract,Portsmouth,Preston,Quatford,Reading,Redcliff,Retford,Rockingham,Romney,Rothbury,Rothwell,Salisbury,Saltash,Seaford,Seasalter,Sherston,Shifnal,Shoreham,Sidmouth,Skipsea,Skipton,Solihull,Somerton,Southam,Southwark,Standon,Stansted,Stapleton,Stottesdon,Sudbury,Swavesey,Tamerton,Tarporley,Tetbury,Thatcham,Thaxted,Thetford,Thornbury,Tintagel,Tiverton,Torksey,Totnes,Towcester,Tregoney,Trematon,Tutbury,Uxbridge,Wallingford,Wareham,Warenmouth,Wargrave,Warton,Watchet,Watford,Wendover,Westbury,Westcheap,Weymouth,Whitford,Wickwar,Wigan,Wigmore,Winchelsea,Winkleigh,Wiscombe,Witham,Witheridge,Wiveliscombe,Woodbury,Yeovil"},
{name: "French", i: 2, min: 5, max: 13, d: "nlrs", m: .1, b: "Adon,Aillant,Amilly,Andonville,Ardon,Artenay,Ascheres,Ascoux,Attray,Aubin,Audeville,Aulnay,Autruy,Auvilliers,Auxy,Aveyron,Baccon,Bardon,Barville,Batilly,Baule,Bazoches,Beauchamps,Beaugency,Beaulieu,Beaune,Bellegarde,Boesses,Boigny,Boiscommun,Boismorand,Boisseaux,Bondaroy,Bonnee,Bonny,Bordes,Bou,Bougy,Bouilly,Boulay,Bouzonville,Bouzy,Boynes,Bray,Breteau,Briare,Briarres,Bricy,Bromeilles,Bucy,Cepoy,Cercottes,Cerdon,Cernoy,Cesarville,Chailly,Chaingy,Chalette,Chambon,Champoulet,Chanteau,Chantecoq,Chapell,Charme,Charmont,Charsonville,Chateau,Chateauneuf,Chatel,Chatenoy,Chatillon,Chaussy,Checy,Chevannes,Chevillon,Chevilly,Chevry,Chilleurs,Choux,Chuelles,Clery,Coinces,Coligny,Combleux,Combreux,Conflans,Corbeilles,Corquilleroy,Cortrat,Coudroy,Coullons,Coulmiers,Courcelles,Courcy,Courtemaux,Courtempierre,Courtenay,Cravant,Crottes,Dadonville,Dammarie,Dampierre,Darvoy,Desmonts,Dimancheville,Donnery,Dordives,Dossainville,Douchy,Dry,Echilleuses,Egry,Engenville,Epieds,Erceville,Ervauville,Escrennes,Escrignelles,Estouy,Faverelles,Fay,Feins,Ferolles,Ferrieres,Fleury,Fontenay,Foret,Foucherolles,Freville,Gatinais,Gaubertin,Gemigny,Germigny,Gidy,Gien,Girolles,Givraines,Gondreville,Grangermont,Greneville,Griselles,Guigneville,Guilly,Gyleslonains,Huetre,Huisseau,Ingrannes,Ingre,Intville,Isdes,Jargeau,Jouy,Juranville,Bussiere,Laas,Ladon,Lailly,Langesse,Leouville,Ligny,Lombreuil,Lorcy,Lorris,Loury,Louzouer,Malesherbois,Marcilly,Mardie,Mareau,Marigny,Marsainvilliers,Melleroy,Menestreau,Merinville,Messas,Meung,Mezieres,Migneres,Mignerette,Mirabeau,Montargis,Montbarrois,Montbouy,Montcresson,Montereau,Montigny,Montliard,Mormant,Morville,Moulinet,Moulon,Nancray,Nargis,Nesploy,Neuville,Neuvy,Nevoy,Nibelle,Nogent,Noyers,Ocre,Oison,Olivet,Ondreville,Onzerain,Orleans,Ormes,Orville,Oussoy,Outarville,Ouzouer,Pannecieres,Pannes,Patay,Paucourt,Pers,Pierrefitte,Pithiverais,Pithiviers,Poilly,Potier,Prefontaines,Presnoy,Pressigny,Puiseaux,Quiers,Ramoulu,Rebrechien,Rouvray,Rozieres,Rozoy,Ruan,Sandillon,Santeau,Saran,Sceaux,Seichebrieres,Semoy,Sennely,Sermaises,Sigloy,Solterre,Sougy,Sully,Sury,Tavers,Thignonville,Thimory,Thorailles,Thou,Tigy,Tivernon,Tournoisis,Trainou,Treilles,Trigueres,Trinay,Vannes,Varennes,Vennecy,Vieilles,Vienne,Viglain,Vignes,Villamblain,Villemandeur,Villemoutiers,Villemurlin,Villeneuve,Villereau,Villevoques,Villorceau,Vimory,Vitry,Vrigny,Ivre"},
{name: "Italian", i: 3, min: 5, max: 12, d: "cltr", m: .1, b: "Accumoli,Acquafondata,Acquapendente,Acuto,Affile,Agosta,Alatri,Albano,Allumiere,Alvito,Amaseno,Amatrice,Anagni,Anguillara,Anticoli,Antrodoco,Anzio,Aprilia,Aquino,Arce,Arcinazzo,Ardea,Ariccia,Arlena,Arnara,Arpino,Arsoli,Artena,Ascrea,Atina,Ausonia,Bagnoregio,Barbarano,Bassano,Bassiano,Bellegra,Belmonte,Blera,Bolsena,Bomarzo,Borbona,Borgo,Borgorose,Boville,Bracciano,Broccostella,Calcata,Camerata,Campagnano,Campodimele,Campoli,Canale,Canepina,Canino,Cantalice,Cantalupo,Canterano,Capena,Capodimonte,Capranica,Caprarola,Carbognano,Casalattico,Casalvieri,Casape,Casaprota,Casperia,Cassino,Castelforte,Castelliri,Castello,Castelnuovo,Castiglione,Castro,Castrocielo,Cave,Ceccano,Celleno,Cellere,Ceprano,Cerreto,Cervara,Cervaro,Cerveteri,Ciampino,Ciciliano,Cineto,Cisterna,Cittaducale,Cittareale,Civita,Civitavecchia,Civitella,Colfelice,Collalto,Colle,Colleferro,Collegiove,Collepardo,Collevecchio,Colli,Colonna,Concerviano,Configni,Contigliano,Corchiano,Coreno,Cori,Cottanello,Esperia,Fabrica,Faleria,Fara,Farnese,Ferentino,Fiamignano,Fiano,Filacciano,Filettino,Fiuggi,Fiumicino,Fondi,Fontana,Fonte,Fontechiari,Forano,Formello,Formia,Frascati,Frasso,Frosinone,Fumone,Gaeta,Gallese,Gallicano,Gallinaro,Gavignano,Genazzano,Genzano,Gerano,Giuliano,Gorga,Gradoli,Graffignano,Greccio,Grottaferrata,Grotte,Guarcino,Guidonia,Ischia,Isola,Itri,Jenne,Labico,Labro,Ladispoli,Lanuvio,Lariano,Latera,Lenola,Leonessa,Licenza,Longone,Lubriano,Maenza,Magliano,Mandela,Manziana,Marano,Marcellina,Marcetelli,Marino,Marta,Mazzano,Mentana,Micigliano,Minturno,Mompeo,Montalto,Montasola,Monte,Montebuono,Montefiascone,Monteflavio,Montelanico,Monteleone,Montelibretti,Montenero,Monterosi,Monterotondo,Montopoli,Montorio,Moricone,Morlupo,Morolo,Morro,Nazzano,Nemi,Nepi,Nerola,Nespolo,Nettuno,Norma,Olevano,Onano,Oriolo,Orte,Orvinio,Paganico,Palestrina,Paliano,Palombara,Pastena,Patrica,Percile,Pescorocchiano,Pescosolido,Petrella,Piansano,Picinisco,Pico,Piedimonte,Piglio,Pignataro,Pisoniano,Pofi,Poggio,Poli,Pomezia,Pontecorvo,Pontinia,Ponza,Ponzano,Posta,Pozzaglia,Priverno,Proceno,Prossedi,Riano,Rieti,Rignano,Riofreddo,Ripi,Rivodutri,Rocca,Roccagiovine,Roccagorga,Roccantica,Roccasecca,Roiate,Ronciglione,Roviano,Sabaudia,Sacrofano,Salisano,Sambuci,Santa,Santi,Santopadre,Saracinesco,Scandriglia,Segni,Selci,Sermoneta,Serrone,Settefrati,Sezze,Sgurgola,Sonnino,Sora,Soriano,Sperlonga,Spigno,Stimigliano,Strangolagalli,Subiaco,Supino,Sutri,Tarano,Tarquinia,Terelle,Terracina,Tessennano,Tivoli,Toffia,Tolfa,Torre,Torri,Torrice,Torricella,Torrita,Trevi,Trevignano,Trivigliano,Turania,Tuscania,Vacone,Valentano,Vallecorsa,Vallemaio,Vallepietra,Vallerano,Vallerotonda,Vallinfreda,Valmontone,Varco,Vasanello,Vejano,Velletri,Ventotene,Veroli,Vetralla,Vicalvi,Vico,Vicovaro,Vignanello,Viterbo,Viticuso,Vitorchiano,Vivaro,Zagarolo"},
{name: "Castillian", i: 4, min: 5, max: 11, d: "lr", m: 0, b: "Abanades,Ablanque,Adobes,Ajofrin,Alameda,Alaminos,Alarilla,Albalate,Albares,Albarreal,Albendiego,Alcabon,Alcanizo,Alcaudete,Alcocer,Alcolea,Alcoroches,Aldea,Aldeanueva,Algar,Algora,Alhondiga,Alique,Almadrones,Almendral,Almoguera,Almonacid,Almorox,Alocen,Alovera,Alustante,Angon,Anguita,Anover,Anquela,Arbancon,Arbeteta,Arcicollar,Argecilla,Arges,Armallones,Armuna,Arroyo,Atanzon,Atienza,Aunon,Azuqueca,Azutan,Baides,Banos,Banuelos,Barcience,Bargas,Barriopedro,Belvis,Berninches,Borox,Brihuega,Budia,Buenaventura,Bujalaro,Burguillos,Burujon,Bustares,Cabanas,Cabanillas,Calera,Caleruela,Calzada,Camarena,Campillo,Camunas,Canizar,Canredondo,Cantalojas,Cardiel,Carmena,Carranque,Carriches,Casa,Casarrubios,Casas,Casasbuenas,Caspuenas,Castejon,Castellar,Castilforte,Castillo,Castilnuevo,Cazalegas,Cebolla,Cedillo,Cendejas,Centenera,Cervera,Checa,Chequilla,Chillaron,Chiloeches,Chozas,Chueca,Cifuentes,Cincovillas,Ciruelas,Ciruelos,Cobeja,Cobeta,Cobisa,Cogollor,Cogolludo,Condemios,Congostrina,Consuegra,Copernal,Corduente,Corral,Cuerva,Domingo,Dosbarrios,Driebes,Duron,El,Embid,Erustes,Escalona,Escalonilla,Escamilla,Escariche,Escopete,Espinosa,Espinoso,Esplegares,Esquivias,Estables,Estriegana,Fontanar,Fuembellida,Fuensalida,Fuentelsaz,Gajanejos,Galve,Galvez,Garciotum,Gascuena,Gerindote,Guadamur,Henche,Heras,Herreria,Herreruela,Hijes,Hinojosa,Hita,Hombrados,Hontanar,Hontoba,Horche,Hormigos,Huecas,Huermeces,Huerta,Hueva,Humanes,Illan,Illana,Illescas,Iniestola,Irueste,Jadraque,Jirueque,Lagartera,Las,Layos,Ledanca,Lillo,Lominchar,Loranca,Los,Lucillos,Lupiana,Luzaga,Luzon,Madridejos,Magan,Majaelrayo,Malaga,Malaguilla,Malpica,Mandayona,Mantiel,Manzaneque,Maqueda,Maranchon,Marchamalo,Marjaliza,Marrupe,Mascaraque,Masegoso,Matarrubia,Matillas,Mazarete,Mazuecos,Medranda,Megina,Mejorada,Mentrida,Mesegar,Miedes,Miguel,Millana,Milmarcos,Mirabueno,Miralrio,Mocejon,Mochales,Mohedas,Molina,Monasterio,Mondejar,Montarron,Mora,Moratilla,Morenilla,Muduex,Nambroca,Navalcan,Negredo,Noblejas,Noez,Nombela,Noves,Numancia,Nuno,Ocana,Ocentejo,Olias,Olmeda,Ontigola,Orea,Orgaz,Oropesa,Otero,Palmaces,Palomeque,Pantoja,Pardos,Paredes,Pareja,Parrillas,Pastrana,Pelahustan,Penalen,Penalver,Pepino,Peralejos,Peralveche,Pinilla,Pioz,Piqueras,Polan,Portillo,Poveda,Pozo,Pradena,Prados,Puebla,Puerto,Pulgar,Quer,Quero,Quintanar,Quismondo,Rebollosa,Recas,Renera,Retamoso,Retiendas,Riba,Rielves,Rillo,Riofrio,Robledillo,Robledo,Romanillos,Romanones,Rueda,Sacecorbo,Sacedon,Saelices,Salmeron,San,Santa,Santiuste,Santo,Sartajada,Sauca,Sayaton,Segurilla,Selas,Semillas,Sesena,Setiles,Sevilleja,Sienes,Siguenza,Solanillos,Somolinos,Sonseca,Sotillo,Sotodasos,Talavera,Tamajon,Taragudo,Taravilla,Tartanedo,Tembleque,Tendilla,Terzaga,Tierzo,Tordellego,Tordelrabano,Tordesilos,Torija,Torralba,Torre,Torrecilla,Torrecuadrada,Torrejon,Torremocha,Torrico,Torrijos,Torrubia,Tortola,Tortuera,Tortuero,Totanes,Traid,Trijueque,Trillo,Turleque,Uceda,Ugena,Ujados,Urda,Utande,Valdarachas,Valdesotos,Valhermoso,Valtablado,Valverde,Velada,Viana,Vinuelas,Yebes,Yebra,Yelamos,Yeles,Yepes,Yuncler,Yunclillos,Yuncos,Yunquera,Zaorejas,Zarzuela,Zorita"},
{name: "Ruthenian", i: 5, min: 5, max: 10, d: "", m: 0, b: "Belgorod,Beloberezhye,Belyi,Belz,Berestiy,Berezhets,Berezovets,Berezutsk,Bobruisk,Bolonets,Borisov,Borovsk,Bozhesk,Bratslav,Bryansk,Brynsk,Buryn,Byhov,Chechersk,Chemesov,Cheremosh,Cherlen,Chern,Chernigov,Chernitsa,Chernobyl,Chernogorod,Chertoryesk,Chetvertnia,Demyansk,Derevesk,Devyagoresk,Dichin,Dmitrov,Dorogobuch,Dorogobuzh,Drestvin,Drokov,Drutsk,Dubechin,Dubichi,Dubki,Dubkov,Dveren,Galich,Glebovo,Glinsk,Goloty,Gomiy,Gorodets,Gorodische,Gorodno,Gorohovets,Goroshin,Gorval,Goryshon,Holm,Horobor,Hoten,Hotin,Hotmyzhsk,Ilovech,Ivan,Izborsk,Izheslavl,Kamenets,Kanev,Karachev,Karna,Kavarna,Klechesk,Klyapech,Kolomyya,Kolyvan,Kopyl,Korec,Kornik,Korochunov,Korshev,Korsun,Koshkin,Kotelno,Kovyla,Kozelsk,Kozelsk,Kremenets,Krichev,Krylatsk,Ksniatin,Kulatsk,Kursk,Kursk,Lebedev,Lida,Logosko,Lomihvost,Loshesk,Loshichi,Lubech,Lubno,Lubutsk,Lutsk,Luchin,Luki,Lukoml,Luzha,Lvov,Mtsensk,Mdin,Medniki,Melecha,Merech,Meretsk,Mescherskoe,Meshkovsk,Metlitsk,Mezetsk,Mglin,Mihailov,Mikitin,Mikulino,Miloslavichi,Mogilev,Mologa,Moreva,Mosalsk,Moschiny,Mozyr,Mstislav,Mstislavets,Muravin,Nemech,Nemiza,Nerinsk,Nichan,Novgorod,Novogorodok,Obolichi,Obolensk,Obolensk,Oleshsk,Olgov,Omelnik,Opoka,Opoki,Oreshek,Orlets,Osechen,Oster,Ostrog,Ostrov,Perelai,Peremil,Peremyshl,Pererov,Peresechen,Perevitsk,Pereyaslav,Pinsk,Ples,Polotsk,Pronsk,Proposhesk,Punia,Putivl,Rechitsa,Rodno,Rogachev,Romanov,Romny,Roslavl,Rostislavl,Rostovets,Rsha,Ruza,Rybchesk,Rylsk,Rzhavesk,Rzhev,Rzhischev,Sambor,Serensk,Serensk,Serpeysk,Shilov,Shuya,Sinech,Sizhka,Skala,Slovensk,Slutsk,Smedin,Sneporod,Snitin,Snovsk,Sochevo,Sokolec,Starica,Starodub,Stepan,Sterzh,Streshin,Sutesk,Svinetsk,Svisloch,Terebovl,Ternov,Teshilov,Teterin,Tiversk,Torchevsk,Toropets,Torzhok,Tripolye,Trubchevsk,Tur,Turov,Usvyaty,Uteshkov,Vasilkov,Velil,Velye,Venev,Venicha,Verderev,Vereya,Veveresk,Viazma,Vidbesk,Vidychev,Voino,Volodimer,Volok,Volyn,Vorobesk,Voronich,Voronok,Vorotynsk,Vrev,Vruchiy,Vselug,Vyatichsk,Vyatka,Vyshegorod,Vyshgorod,Vysokoe,Yagniatin,Yaropolch,Yasenets,Yuryev,Yuryevets,Zaraysk,Zhitomel,Zholvazh,Zizhech,Zubkov,Zudechev,Zvenigorod"},
{name: "Nordic", i: 6, min: 6, max: 10, d: "kln", m: .1, b: "Akureyri,Aldra,Alftanes,Andenes,Austbo,Auvog,Bakkafjordur,Ballangen,Bardal,Beisfjord,Bifrost,Bildudalur,Bjerka,Bjerkvik,Bjorkosen,Bliksvaer,Blokken,Blonduos,Bolga,Bolungarvik,Borg,Borgarnes,Bosmoen,Bostad,Bostrand,Botsvika,Brautarholt,Breiddalsvik,Bringsli,Brunahlid,Budardalur,Byggdakjarni,Dalvik,Djupivogur,Donnes,Drageid,Drangsnes,Egilsstadir,Eiteroga,Elvenes,Engavogen,Ertenvog,Eskifjordur,Evenes,Eyrarbakki,Fagernes,Fallmoen,Fellabaer,Fenes,Finnoya,Fjaer,Fjelldal,Flakstad,Flateyri,Flostrand,Fludir,Gardaber,Gardur,Gimstad,Givaer,Gjeroy,Gladstad,Godoya,Godoynes,Granmoen,Gravdal,Grenivik,Grimsey,Grindavik,Grytting,Hafnir,Halsa,Hauganes,Haugland,Hauknes,Hella,Helland,Hellissandur,Hestad,Higrav,Hnifsdalur,Hofn,Hofsos,Holand,Holar,Holen,Holkestad,Holmavik,Hopen,Hovden,Hrafnagil,Hrisey,Husavik,Husvik,Hvammstangi,Hvanneyri,Hveragerdi,Hvolsvollur,Igeroy,Indre,Inndyr,Innhavet,Innes,Isafjordur,Jarklaustur,Jarnsreykir,Junkerdal,Kaldvog,Kanstad,Karlsoy,Kavosen,Keflavik,Kjelde,Kjerstad,Klakk,Kopasker,Kopavogur,Korgen,Kristnes,Krutoga,Krystad,Kvina,Lande,Laugar,Laugaras,Laugarbakki,Laugarvatn,Laupstad,Leines,Leira,Leiren,Leland,Lenvika,Loding,Lodingen,Lonsbakki,Lopsmarka,Lovund,Luroy,Maela,Melahverfi,Meloy,Mevik,Misvaer,Mornes,Mosfellsber,Moskenes,Myken,Naurstad,Nesberg,Nesjahverfi,Nesset,Nevernes,Obygda,Ofoten,Ogskardet,Okervika,Oknes,Olafsfjordur,Oldervika,Olstad,Onstad,Oppeid,Oresvika,Orsnes,Orsvog,Osmyra,Overdal,Prestoya,Raudalaekur,Raufarhofn,Reipo,Reykholar,Reykholt,Reykjahlid,Rif,Rinoya,Rodoy,Rognan,Rosvika,Rovika,Salhus,Sanden,Sandgerdi,Sandoker,Sandset,Sandvika,Saudarkrokur,Selfoss,Selsoya,Sennesvik,Setso,Siglufjordur,Silvalen,Skagastrond,Skjerstad,Skonland,Skorvogen,Skrova,Sleneset,Snubba,Softing,Solheim,Solheimar,Sorarnoy,Sorfugloy,Sorland,Sormela,Sorvaer,Sovika,Stamsund,Stamsvika,Stave,Stokka,Stokkseyri,Storjord,Storo,Storvika,Strand,Straumen,Strendene,Sudavik,Sudureyri,Sundoya,Sydalen,Thingeyri,Thorlakshofn,Thorshofn,Tjarnabyggd,Tjotta,Tosbotn,Traelnes,Trofors,Trones,Tverro,Ulvsvog,Unnstad,Utskor,Valla,Vandved,Varmahlid,Vassos,Vevelstad,Vidrek,Vik,Vikholmen,Vogar,Vogehamn,Vopnafjordur"},
{name: "Greek", i: 7, min: 5, max: 11, d: "s", m: .1, b: "Abdera,Abila,Abydos,Acanthus,Acharnae,Actium,Adramyttium,Aegae,Aegina,Aegium,Aenus,Agrinion,Aigosthena,Akragas,Akrai,Akrillai,Akroinon,Akrotiri,Alalia,Alexandreia,Alexandretta,Alexandria,Alinda,Amarynthos,Amaseia,Ambracia,Amida,Amisos,Amnisos,Amphicaea,Amphigeneia,Amphipolis,Amphissa,Ankon,Antigona,Antipatrea,Antioch,Antioch,Antiochia,Andros,Apamea,Aphidnae,Apollonia,Argos,Arsuf,Artanes,Artemita,Argyroupoli,Asine,Asklepios,Aspendos,Assus,Astacus,Athenai,Athmonia,Aytos,Ancient,Baris,Bhrytos,Borysthenes,Berge,Boura,Bouthroton,Brauron,Byblos,Byllis,Byzantium,Bythinion,Callipolis,Cebrene,Chalcedon,Calydon,Carystus,Chamaizi,Chalcis,Chersonesos,Chios,Chytri,Clazomenae,Cleonae,Cnidus,Colosse,Corcyra,Croton,Cyme,Cyrene,Cythera,Decelea,Delos,Delphi,Demetrias,Dicaearchia,Dimale,Didyma,Dion,Dioscurias,Dodona,Dorylaion,Dyme,Edessa,Elateia,Eleusis,Eleutherna,Emporion,Ephesus,Ephyra,Epidamnos,Epidauros,Eresos,Eretria,Erythrae,Eubea,Gangra,Gaza,Gela,Golgi,Gonnos,Gorgippia,Gournia,Gortyn,Gythium,Hagios,Hagia,Halicarnassus,Halieis,Helike,Heliopolis,Hellespontos,Helorus,Hemeroskopeion,Heraclea,Hermione,Hermonassa,Hierapetra,Hierapolis,Himera,Histria,Hubla,Hyele,Ialysos,Iasus,Idalium,Imbros,Iolcus,Itanos,Ithaca,Juktas,Kallipolis,Kamares,Kameiros,Kannia,Kamarina,Kasmenai,Katane,Kerkinitida,Kepoi,Kimmerikon,Kios,Klazomenai,Knidos,Knossos,Korinthos,Kos,Kourion,Kume,Kydonia,Kynos,Kyrenia,Lamia,Lampsacus,Laodicea,Lapithos,Larissa,Lato,Laus,Lebena,Lefkada,Lekhaion,Leibethra,Leontinoi,Lepreum,Lessa,Lilaea,Lindus,Lissus,Epizephyrian,Madytos,Magnesia,Mallia,Mantineia,Marathon,Marmara,Maroneia,Masis,Massalia,Megalopolis,Megara,Mesembria,Messene,Metapontum,Methana,Methone,Methumna,Miletos,Misenum,Mochlos,Monastiraki,Morgantina,Mulai,Mukenai,Mylasa,Myndus,Myonia,Myra,Myrmekion,Mutilene,Myos,Nauplios,Naucratis,Naupactus,Naxos,Neapoli,Neapolis,Nemea,Nicaea,Nicopolis,Nirou,Nymphaion,Nysa,Oenoe,Oenus,Odessos,Olbia,Olous,Olympia,Olynthus,Opus,Orchomenus,Oricos,Orestias,Oreus,Oropus,Onchesmos,Pactye,Pagasae,Palaikastro,Pandosia,Panticapaeum,Paphos,Parium,Paros,Parthenope,Patrae,Pavlopetri,Pegai,Pelion,Peiraies,Pella,Percote,Pergamum,Petsofa,Phaistos,Phaleron,Phanagoria,Pharae,Pharnacia,Pharos,Phaselis,Philippi,Pithekussa,Philippopolis,Platanos,Phlius,Pherae,Phocaea,Pinara,Pisa,Pitane,Pitiunt,Pixous,Plataea,Poseidonia,Potidaea,Priapus,Priene,Prousa,Pseira,Psychro,Pteleum,Pydna,Pylos,Pyrgos,Rhamnus,Rhegion,Rhithymna,Rhodes,Rhypes,Rizinia,Salamis,Same,Samos,Scyllaeum,Selinus,Seleucia,Semasus,Sestos,Scidrus,Sicyon,Side,Sidon,Siteia,Sinope,Siris,Sklavokampos,Smyrna,Soli,Sozopolis,Sparta,Stagirus,Stratos,Stymphalos,Sybaris,Surakousai,Taras,Tanagra,Tanais,Tauromenion,Tegea,Temnos,Tenedos,Tenea,Teos,Thapsos,Thassos,Thebai,Theodosia,Therma,Thespiae,Thronion,Thoricus,Thurii,Thyreum,Thyria,Tiruns,Tithoraea,Tomis,Tragurion,Trapeze,Trapezus,Tripolis,Troizen,Troliton,Troy,Tylissos,Tyras,Tyros,Tyritake,Vasiliki,Vathypetros,Zakynthos,Zakros,Zankle"},
{name: "Roman", i: 8, min: 6, max: 11, d: "ln", m: .1, b: "Abila,Adflexum,Adnicrem,Aelia,Aelius,Aeminium,Aequum,Agrippina,Agrippinae,Ala,Albanianis,Ambianum,Andautonia,Apulum,Aquae,Aquaegranni,Aquensis,Aquileia,Aquincum,Arae,Argentoratum,Ariminum,Ascrivium,Atrebatum,Atuatuca,Augusta,Aurelia,Aurelianorum,Batavar,Batavorum,Belum,Biriciana,Blestium,Bonames,Bonna,Bononia,Borbetomagus,Bovium,Bracara,Brigantium,Burgodunum,Caesaraugusta,Caesarea,Caesaromagus,Calleva,Camulodunum,Cannstatt,Cantiacorum,Capitolina,Castellum,Castra,Castrum,Cibalae,Clausentum,Colonia,Concangis,Condate,Confluentes,Conimbriga,Corduba,Coria,Corieltauvorum,Corinium,Coriovallum,Cornoviorum,Danum,Deva,Divodurum,Dobunnorum,Drusi,Dubris,Dumnoniorum,Durnovaria,Durocobrivis,Durocornovium,Duroliponte,Durovernum,Durovigutum,Eboracum,Edetanorum,Emerita,Emona,Euracini,Faventia,Flaviae,Florentia,Forum,Gerulata,Gerunda,Glevensium,Hadriani,Herculanea,Isca,Italica,Iulia,Iuliobrigensium,Iuvavum,Lactodurum,Lagentium,Lauri,Legionis,Lemanis,Lentia,Lepidi,Letocetum,Lindinis,Lindum,Londinium,Lopodunum,Lousonna,Lucus,Lugdunum,Luguvalium,Lutetia,Mancunium,Marsonia,Martius,Massa,Matilo,Mattiacorum,Mediolanum,Mod,Mogontiacum,Moridunum,Mursa,Naissus,Nervia,Nida,Nigrum,Novaesium,Noviomagus,Olicana,Ovilava,Parisiorum,Partiscum,Paterna,Pistoria,Placentia,Pollentia,Pomaria,Pons,Portus,Praetoria,Praetorium,Pullum,Ragusium,Ratae,Raurica,Regina,Regium,Regulbium,Rigomagus,Roma,Romula,Rutupiae,Salassorum,Salernum,Salona,Scalabis,Segovia,Silurum,Sirmium,Siscia,Sorviodurum,Sumelocenna,Tarraco,Taurinorum,Theranda,Traiectum,Treverorum,Tungrorum,Turicum,Ulpia,Valentia,Venetiae,Venta,Verulamium,Vesontio,Vetera,Victoriae,Victrix,Villa,Viminacium,Vindelicorum,Vindobona,Vinovia,Viroconium"},
{name: "Finnic", i: 9, min: 5, max: 11, d: "akiut", m: 0, b: "Aanekoski,Abjapaluoja,Ahlainen,Aholanvaara,Ahtari,Aijala,Aimala,Akaa,Alajarvi,Alatornio,Alavus,Antsla,Aspo,Bennas,Bjorkoby,Elva,Emasalo,Espoo,Esse,Evitskog,Forssa,Haapajarvi,Haapamaki,Haapavesi,Haapsalu,Haavisto,Hameenlinna,Hameenmaki,Hamina,Hanko,Harjavalta,Hattuvaara,Haukipudas,Hautajarvi,Havumaki,Heinola,Hetta,Hinkabole,Hirmula,Hossa,Huittinen,Husula,Hyryla,Hyvinkaa,Iisalmi,Ikaalinen,Ilmola,Imatra,Inari,Iskmo,Itakoski,Jamsa,Jarvenpaa,Jeppo,Jioesuu,Jiogeva,Joensuu,Jokela,Jokikyla,Jokisuu,Jormua,Juankoski,Jungsund,Jyvaskyla,Kaamasmukka,Kaarina,Kajaani,Kalajoki,Kallaste,Kankaanpaa,Kannus,Kardla,Karesuvanto,Karigasniemi,Karkkila,Karkku,Karksinuia,Karpankyla,Kaskinen,Kasnas,Kauhajoki,Kauhava,Kauniainen,Kauvatsa,Kehra,Keila,Kellokoski,Kelottijarvi,Kemi,Kemijarvi,Kerava,Keuruu,Kiikka,Kiipu,Kilinginiomme,Kiljava,Kilpisjarvi,Kitee,Kiuruvesi,Kivesjarvi,Kiviioli,Kivisuo,Klaukkala,Klovskog,Kohtlajarve,Kokemaki,Kokkola,Kolho,Koria,Koskue,Kotka,Kouva,Kouvola,Kristiina,Kaupunki,Kuhmo,Kunda,Kuopio,Kuressaare,Kurikka,Kusans,Kuusamo,Kylmalankyla,Lahti,Laitila,Lankipohja,Lansikyla,Lappeenranta,Lapua,Laurila,Lautiosaari,Lepsama,Liedakkala,Lieksa,Lihula,Littoinen,Lohja,Loimaa,Loksa,Loviisa,Luohuanylipaa,Lusi,Maardu,Maarianhamina,Malmi,Mantta,Masaby,Masala,Matasvaara,Maula,Miiluranta,Mikkeli,Mioisakula,Munapirtti,Mustvee,Muurahainen,Naantali,Nappa,Narpio,Nickby,Niinimaa,Niinisalo,Nikkila,Nilsia,Nivala,Nokia,Nummela,Nuorgam,Nurmes,Nuvvus,Obbnas,Oitti,Ojakkala,Ollola,onningeby,Orimattila,Orivesi,Otanmaki,Otava,Otepaa,Oulainen,Oulu,Outokumpu,Paavola,Paide,Paimio,Pakankyla,Paldiski,Parainen,Parkano,Parkumaki,Parola,Perttula,Pieksamaki,Pietarsaari,Pioltsamaa,Piolva,Pohjavaara,Porhola,Pori,Porrasa,Porvoo,Pudasjarvi,Purmo,Pussi,Pyhajarvi,Raahe,Raasepori,Raisio,Rajamaki,Rakvere,Rapina,Rapla,Rauma,Rautio,Reposaari,Riihimaki,Rovaniemi,Roykka,Ruonala,Ruottala,Rutalahti,Saarijarvi,Salo,Sastamala,Saue,Savonlinna,Seinajoki,Sillamae,Sindi,Siuntio,Somero,Sompujarvi,Suonenjoki,Suurejaani,Syrjantaka,Tampere,Tamsalu,Tapa,Temmes,Tiorva,Tormasenvaara,Tornio,Tottijarvi,Tulppio,Turenki,Turi,Tuukkala,Tuurala,Tuuri,Tuuski,Ulvila,Unari,Upinniemi,Utti,Uusikaarlepyy,Uusikaupunki,Vaaksy,Vaalimaa,Vaarinmaja,Vaasa,Vainikkala,Valga,Valkeakoski,Vantaa,Varkaus,Vehkapera,Vehmasmaki,Vieki,Vierumaki,Viitasaari,Viljandi,Vilppula,Viohma,Vioru,Virrat,Ylike,Ylivieska,Ylojarvi"},
{name: "Korean", i: 10, min: 5, max: 11, d: "", m: 0, b: "Aewor,Andong,Angang,Anjung,Anmyeon,Ansan,Anseong,Anyang,Aphae,Apo,Asan,Baebang,Baekseok,Baeksu,Beobwon,Beolgyo,Beomseo,Boeun,Bongdam,Bongdong,Bonghwa,Bongyang,Boryeong,Boseong,Buan,Bubal,Bucheon,Buksam,Busan,Busan,Busan,Buyeo,Changnyeong,Changwon,Cheonan,Cheongdo,Cheongjin,Cheongju,Cheongju,Cheongsong,Cheongyang,Cheorwon,Chirwon,Chowol,Chuncheon,Chuncheon,Chungju,Chungmu,Daecheon,Daedeok,Daegaya,Daegu,Daegu,Daegu,Daejeon,Daejeon,Daejeon,Daejeong,Daesan,Damyang,Dangjin,Danyang,Dasa,Dogye,Dolsan,Dong,Dongducheon,Donggwangyang,Donghae,Dongsong,Doyang,Eonyang,Eumseong,Gaeseong,Galmal,Gampo,Ganam,Ganggyeong,Ganghwa,Gangjin,Gangneung,Ganseong,Gapyeong,Gaun,Gaya,Geochang,Geoje,Geojin,Geoncheon,Geumho,Geumil,Geumsan,Geumseong,Geumwang,Gijang,Gimcheon,Gimhae,Gimhwa,Gimje,Gimpo,Goa,Gochang,Gochon,Goesan,Gohan,Goheung,Gokseong,Gongdo,Gongju,Gonjiam,Goseong,Goyang,Gujwa,Gumi,Gungnae,Gunpo,Gunsan,Gunsan,Gunwi,Guri,Gurye,Guryongpo,Gwacheon,Gwangcheon,Gwangju,Gwangju,Gwangju,Gwangju,Gwangmyeong,Gwangyang,Gwansan,Gyeongju,Gyeongsan,Gyeongseong,Gyeongseong,Gyeryong,Hadong,Haeju,Haenam,Hamchang,Hamheung,Hampyeong,Hamyang,Hamyeol,Hanam,Hanrim,Hapcheon,Hapdeok,Hayang,Heunghae,Heungnam,Hoengseong,Hongcheon,Hongnong,Hongseong,Hwacheon,Hwado,Hwando,Hwaseong,Hwasun,Hwawon,Hyangnam,Icheon,Iksan,Illo,Imsil,Incheon,Incheon,Incheon,Inje,Iri,Iri,Jangan,Janghang,Jangheung,Janghowon,Jangseong,Jangseungpo,Jangsu,Jecheon,Jeju,Jeomchon,Jeongeup,Jeonggwan,Jeongju,Jeongok,Jeongseon,Jeonju,Jeonju,Jeungpyeong,Jido,Jiksan,Jillyang,Jinan,Jincheon,Jindo,Jingeon,Jinhae,Jinjeop,Jinju,Jinju,Jinnampo,Jinyeong,Jocheon,Jochiwon,Jori,Judeok,Jumunjin,Maepo,Mangyeong,Masan,Masan,Migeum,Miryang,Mokcheon,Mokpo,Mokpo,Muan,Muju,Mungyeong,Munmak,Munsan,Munsan,Naeseo,Naesu,Najin,Naju,Namhae,Namji,Nampyeong,Namwon,Namyang,Namyangju,Nohwa,Nongong,Nonsan,Ochang,Ocheon,Oedong,Okcheon,Okgu,Onam,Onsan,Onyang,Opo,Osan,Osong,Paengseong,Paju,Pocheon,Pogok,Pohang,Poseung,Punggi,Pungsan,Pyeongchang,Pyeonghae,Pyeongtaek,Pyeongyang,Sabi,Sabuk,Sacheon,Samcheok,Samcheonpo,Samho,Samhyang,Samnangjin,Samrye,Sancheong,Sangdong,Sangju,Sanyang,Sapgyo,Sariwon,Sejong,Seocheon,Seogwipo,Seokjeok,Seonggeo,Seonghwan,Seongjin,Seongju,Seongnam,Seongsan,Seonsan,Seosan,Seoul,Seungju,Siheung,Sinbuk,Sindong,Sineuiju,Sintaein,Soheul,Sokcho,Songak,Songjeong,Songnim,Songtan,Sunchang,Suncheon,Suwon,Taean,Taebaek,Tongjin,Tongyeong,Uijeongbu,Uiryeong,Uiseong,Uiwang,Ujeong,Uljin,Ulleung,Ulsan,Ulsan,Unbong,Ungcheon,Ungjin,Wabu,Waegwan,Wando,Wanggeomseong,Wiryeseong,Wondeok,Wonju,Wonsan,Yangchon,Yanggu,Yangju,Yangpyeong,Yangsan,Yangyang,Yecheon,Yeocheon,Yeoju,Yeomchi,Yeoncheon,Yeongam,Yeongcheon,Yeongdeok,Yeongdong,Yeonggwang,Yeongju,Yeongwol,Yeongyang,Yeonil,Yeonmu,Yeosu,Yesan,Yongin,Yongjin,Yugu,Wayang"},
{name: "Chinese", i: 11, min: 5, max: 10, d: "", m: 0, b: "Anding,Anlu,Anqing,Anshun,Baan,Baixing,Banyang,Baoding,Baoqing,Binzhou,Caozhou,Changbai,Changchun,Changde,Changling,Changsha,Changtu,Changzhou,Chaozhou,Cheli,Chengde,Chengdu,Chenzhou,Chizhou,Chongqing,Chuxiong,Chuzhou,Dading,Dali,Daming,Datong,Daxing,Dean,Dengke,Dengzhou,Deqing,Dexing,Dihua,Dingli,Dongan,Dongchang,Dongchuan,Dongping,Duyun,Fengtian,Fengxiang,Fengyang,Fenzhou,Funing,Fuzhou,Ganzhou,Gaoyao,Gaozhou,Gongchang,Guangnan,Guangning,Guangping,Guangxin,Guangzhou,Guide,Guilin,Guiyang,Hailong,Hailun,Hangzhou,Hanyang,Hanzhong,Heihe,Hejian,Henan,Hengzhou,Hezhong,Huaian,Huaide,Huaiqing,Huanglong,Huangzhou,Huining,Huizhou,Hulan,Huzhou,Jiading,Jian,Jianchang,Jiande,Jiangning,Jiankang,Jianning,Jiaxing,Jiayang,Jilin,Jinan,Jingjiang,Jingzhao,Jingzhou,Jinhua,Jinzhou,Jiujiang,Kaifeng,Kaihua,Kangding,Kuizhou,Laizhou,Lanzhou,Leizhou,Liangzhou,Lianzhou,Liaoyang,Lijiang,Linan,Linhuang,Linjiang,Lintao,Liping,Liuzhou,Longan,Longjiang,Longqing,Longxing,Luan,Lubin,Lubin,Luzhou,Mishan,Nanan,Nanchang,Nandian,Nankang,Nanning,Nanyang,Nenjiang,Ningan,Ningbo,Ningguo,Ninguo,Ningwu,Ningxia,Ningyuan,Pingjiang,Pingle,Pingliang,Pingyang,Puer,Puzhou,Qianzhou,Qingyang,Qingyuan,Qingzhou,Qiongzhou,Qujing,Quzhou,Raozhou,Rende,Ruian,Ruizhou,Runing,Shafeng,Shajing,Shaoqing,Shaowu,Shaoxing,Shaozhou,Shinan,Shiqian,Shouchun,Shuangcheng,Shulei,Shunde,Shunqing,Shuntian,Shuoping,Sicheng,Sien,Sinan,Sizhou,Songjiang,Suiding,Suihua,Suining,Suzhou,Taian,Taibei,Tainan,Taiping,Taiwan,Taiyuan,Taizhou,Taonan,Tengchong,Tieli,Tingzhou,Tongchuan,Tongqing,Tongren,Tongzhou,Weihui,Wensu,Wenzhou,Wuchang,Wuding,Wuzhou,Xian,Xianchun,Xianping,Xijin,Xiliang,Xincheng,Xingan,Xingde,Xinghua,Xingjing,Xingqing,Xingyi,Xingyuan,Xingzhong,Xining,Xinmen,Xiping,Xuanhua,Xunzhou,Xuzhou,Yanan,Yangzhou,Yanji,Yanping,Yanqi,Yanzhou,Yazhou,Yichang,Yidu,Yilan,Yili,Yingchang,Yingde,Yingtian,Yingzhou,Yizhou,Yongchang,Yongping,Yongshun,Yongzhou,Yuanzhou,Yuezhou,Yulin,Yunnan,Yunyang,Zezhou,Zhangde,Zhangzhou,Zhaoqing,Zhaotong,Zhenan,Zhending,Zhengding,Zhenhai,Zhenjiang,Zhenxi,Zhenyun,Zhongshan,Zunyi"},
{name: "Japanese", i: 12, min: 4, max: 10, d: "", m: 0, b: "Abira,Aga,Aikawa,Aizumisato,Ajigasawa,Akkeshi,Amagi,Ami,Anan,Ando,Asakawa,Ashikita,Bandai,Biratori,China,Chonan,Esashi,Fuchu,Fujimi,Funagata,Genkai,Godo,Goka,Gonohe,Gyokuto,Haboro,Hamatonbetsu,Happo,Harima,Hashikami,Hayashima,Heguri,Hidaka,Higashiagatsuma,Higashiura,Hiranai,Hirogawa,Hiroo,Hodatsushimizu,Hoki,Hokuei,Hokuryu,Horokanai,Ibigawa,Ichikai,Ichikawamisato,Ichinohe,Iide,Iijima,Iizuna,Ikawa,Inagawa,Itakura,Iwaizumi,Iwate,Kagamino,Kaisei,Kamifurano,Kamiita,Kamijima,Kamikawa,Kamikawa,Kamikawa,Kaminokawa,Kamishihoro,Kamitonda,Kamiyama,Kanda,Kanna,Kasagi,Kasuya,Katsuura,Kawabe,Kawagoe,Kawajima,Kawamata,Kawamoto,Kawanehon,Kawanishi,Kawara,Kawasaki,Kawasaki,Kawatana,Kawazu,Kihoku,Kikonai,Kin,Kiso,Kitagata,Kitajima,Kiyama,Kiyosato,Kofu,Koge,Kohoku,Kokonoe,Kora,Kosa,Kosaka,Kotohira,Kudoyama,Kumejima,Kumenan,Kumiyama,Kunitomi,Kurate,Kushimoto,Kutchan,Kyonan,Kyotamba,Mashike,Matsumae,Mifune,Mihama,Minabe,Minami,Minamiechizen,Minamioguni,Minamiosumi,Minamitane,Misaki,Misasa,Misato,Miyashiro,Miyoshi,Mori,Moseushi,Mutsuzawa,Nagaizumi,Nagatoro,Nagayo,Nagomi,Nakadomari,Nakanojo,Nakashibetsu,Nakatosa,Namegawa,Namie,Nanbu,Nanporo,Naoshima,Nasu,Niseko,Nishihara,Nishiizu,Nishikatsura,Nishikawa,Nishinoshima,Nishiwaga,Nogi,Noto,Nyuzen,Oarai,Obuse,Odai,Ogawara,Oharu,Oi,Oirase,Oishida,Oiso,Oizumi,Oji,Okagaki,Oketo,Okutama,Omu,Ono,Osaki,Osakikamijima,Otobe,Otsuki,Owani,Reihoku,Rifu,Rikubetsu,Rishiri,Rokunohe,Ryuo,Saka,Sakuho,Samani,Satsuma,Sayo,Saza,Setana,Shakotan,Shibayama,Shikama,Shimamoto,Shimizu,Shimokawa,Shintomi,Shirakawa,Shisui,Shitara,Sobetsu,Sue,Sumita,Suooshima,Suttsu,Tabuse,Tachiarai,Tadami,Tadaoka,Taiji,Taiki,Takachiho,Takahama,Taketoyo,Tako,Taragi,Tateshina,Tatsugo,Tawaramoto,Teshikaga,Tobe,Toin,Tokigawa,Toma,Tomioka,Tonosho,Tosa,Toyo,Toyokoro,Toyotomi,Toyoyama,Tsubata,Tsubetsu,Tsukigata,Tsunan,Tsuno,Tsuwano,Umi,Wakasa,Yamamoto,Yamanobe,Yamatsuri,Yanaizu,Yasuda,Yoichi,Yonaguni,Yoro,Yoshino,Yubetsu,Yugawara,Yuni,Yusuhara,Yuza"},
{name: "Portuguese", i: 13, min: 5, max: 11, d: "", m: .1, b: "Abrigada,Afonsoeiro,Agueda,Aguiar,Aguilada,Alagoas,Alagoinhas,Albufeira,Alcacovas,Alcanhoes,Alcobaca,Alcochete,Alcoutim,Aldoar,Alexania,Alfeizerao,Algarve,Alenquer,Almada,Almagreira,Almeirim,Alpalhao,Alpedrinha,Alvalade,Alverca,Alvor,Alvorada,Amadora,Amapa,Amieira,Anapolis,Anhangueira,Ansiaes,Apelacao,Aracaju,Aranhas,Arega,Areira,Araguaina,Araruama,Arganil,Armacao,Arouca,Asfontes,Assenceira,Avelar,Aveiro,Azambuja,Azinheira,Azueira,Bahia,Bairros,Balsas,Barcarena,Barreiras,Barreiro,Barretos,Batalha,Beira,Beja,Benavente,Betim,Boticas,Braga,Braganca,Brasilia,Brejo,Cabecao,Cabeceiras,Cabedelo,Cabofrio,Cachoeiras,Cadafais,Calheta,Calihandriz,Calvao,Camacha,Caminha,Campinas,Canidelo,Canha,Canoas,Capinha,Carmoes,Cartaxo,Carvalhal,Carvoeiro,Cascavel,Castanhal,Castelobranco,Caueira,Caxias,Chapadinha,Chaves,Celheiras,Cocais,Coimbra,Comporta,Coentral,Conde,Copacabana,Coqueirinho,Coruche,Corumba,Couco,Cubatao,Curitiba,Damaia,Doisportos,Douradilho,Dourados,Enxames,Enxara,Erada,Erechim,Ericeira,Ermidasdosado,Ervidel,Escalhao,Escariz,Esmoriz,Estombar,Espinhal,Espinho,Esposende,Esquerdinha,Estela,Estoril,Eunapolis,Evora,Famalicao,Famoes,Fanhoes,Fanzeres,Fatela,Fatima,Faro,Felgueiras,Ferreira,Figueira,Flecheiras,Florianopolis,Fornalhas,Fortaleza,Freiria,Freixeira,Frielas,Fronteira,Funchal,Fundao,Gaeiras,Gafanhadaboahora,Goa,Goiania,Gracas,Gradil,Grainho,Gralheira,Guarulhos,Guetim,Guimaraes,Horta,Iguacu,Igrejanova,Ilhavo,Ilheus,Ipanema,Iraja,Itaboral,Itacuruca,Itaguai,Itanhaem,Itapevi,Juazeiro,Lagos,Lavacolchos,Laies,Lamego,Laranjeiras,Leiria,Limoeiro,Linhares,Lisboa,Lomba,Lorvao,Lourencomarques,Lourical,Lourinha,Luziania,Macao,Macapa,Macedo,Machava,Malveira,Manaus,Mangabeira,Mangaratiba,Marambaia,Maranhao,Maringue,Marinhais,Matacaes,Matosinhos,Maxial,Maxias,Mealhada,Meimoa,Meires,Milharado,Mira,Miranda,Mirandela,Mogadouro,Montalegre,Montesinho,Moura,Mourao,Mozelos,Negroes,Neiva,Nespereira,Nilopolis,Niteroi,Nordeste,Obidos,Odemira,Odivelas,Oeiras,Oleiros,Olhao,Olhalvo,Olhomarinho,Olinda,Olival,Oliveira,Oliveirinha,Oporto,Ourem,Ovar,Palhais,Palheiros,Palmeira,Palmela,Palmital,Pampilhosa,Pantanal,Paradinha,Parelheiros,Paripueira,Paudalho,Pedrosinho,Penafiel,Peniche,Pedrogao,Pegoes,Pinhao,Pinheiro,Pinhel,Pombal,Pontal,Pontinha,Portel,Portimao,Poxim,Quarteira,Queijas,Queluz,Quiaios,Ramalhal,Reboleira,Recife,Redinha,Ribadouro,Ribeira,Ribeirao,Rosais,Roteiro,Sabugal,Sacavem,Sagres,Sandim,Sangalhos,Santarem,Santos,Sarilhos,Sarzedas,Satao,Satuba,Seixal,Seixas,Seixezelo,Seixo,Selmes,Sepetiba,Serta,Setubal,Silvares,Silveira,Sinhaem,Sintra,Sobral,Sobralinho,Sorocaba,Tabuacotavir,Tabuleiro,Taveiro,Teixoso,Telhado,Telheiro,Tomar,Torrao,Torreira,Torresvedras,Tramagal,Trancoso,Troviscal,Vagos,Valpacos,Varzea,Vassouras,Velas,Viana,Vidigal,Vidigueira,Vidual,Viladerei,Vilamar,Vimeiro,Vinhais,Vinhos,Viseu,Vitoria,Vlamao,Vouzela"},
{name: "Nahuatl", i: 14, min: 6, max: 13, d: "l", m: 0, b: "Acaltepec,Acaltepecatl,Acapulco,Acatlan,Acaxochitlan,Ajuchitlan,Atotonilco,Azcapotzalco,Camotlan,Campeche,Chalco,Chapultepec,Chiapan,Chiapas,Chihuahua,Cihuatlan,Cihuatlancihuatl,Coahuila,Coatepec,Coatlan,Coatzacoalcos,Colima,Colotlan,Coyoacan,Cuauhillan,Cuauhnahuac,Cuauhtemoc,Cuernavaca,Ecatepec,Epatlan,Guanajuato,Huaxacac,Huehuetlan,Hueyapan,Ixtapa,Iztaccihuatl,Iztapalapa,Jalisco,Jocotepec,Jocotepecxocotl,Matixco,Mazatlan,Michhuahcan,Michoacan,Michoacanmichin,Minatitlan,Naucalpan,Nayarit,Nezahualcoyotl,Oaxaca,Ocotepec,Ocotlan,Olinalan,Otompan,Popocatepetl,Queretaro,Sonora,Tabasco,Tamaulipas,Tecolotlan,Tenochtitlan,Teocuitlatlan,Teocuitlatlanteotl,Teotlalco,Teotlalcoteotl,Tepotzotlan,Tepoztlantepoztli,Texcoco,Tlachco,Tlalocan,Tlaxcala,Tlaxcallan,Tollocan,Tolutepetl,Tonanytlan,Tototlan,Tuchtlan,Tuxpan,Uaxacac,Xalapa,Xochimilco,Xolotlan,Yaotlan,Yopico,Yucatan,Yztac,Zacatecas,Zacualco"},
{name: "Hungarian", i: 15, min: 6, max: 13, d: "", m: 0.1, b: "Aba,Abadszalok,Abony,Adony,Ajak,Albertirsa,Alsozsolca,Aszod,Babolna,Bacsalmas,Baktaloranthaza,Balassagyarmat,Balatonalmadi,Balatonboglar,Balatonfured,Balatonfuzfo,Balkany,Balmazujvaros,Barcs,Bataszek,Batonyterenye,Battonya,Bekes,Berettyoujfalu,Berhida,Biatorbagy,Bicske,Biharkeresztes,Bodajk,Boly,Bonyhad,Budakalasz,Budakeszi,Celldomolk,Csakvar,Csenger,Csongrad,Csorna,Csorvas,Csurgo,Dabas,Demecser,Derecske,Devavanya,Devecser,Dombovar,Dombrad,Dorogullo,Dunafoldvar,Dunaharaszti,Dunavarsany,Dunavecse,Edeleny,Elek,Emod,Encs,Enying,Ercsi,Fegyvernek,Fehergyarmat,Felsozsolca,Fertoszentmiklos,Fonyod,Fot,Fuzesabony,Fuzesgyarmat,Gardony,God,Gyal,Gyomaendrod,Gyomro,Hajdudorog,Hajduhadhaz,Hajdunanas,Hajdusamson,Hajduszoboszlo,Halasztelek,Harkany,Hatvan,Heves,Heviz,Ibrany,Isaszeg,Izsak,Janoshalma,Janossomorja,Jaszapati,Jaszarokszallas,Jaszfenyszaru,Jaszkiser,Kaba,Kalocsa,Kapuvar,Karcag,Kecel,Kemecse,Kenderes,Kerekegyhaza,Kerepes,Keszthely,Kisber,Kiskoros,Kiskunmajsa,Kistarcsa,Kistelek,Kisujszallas,Kisvarda,Komadi,Komarom,Komlo,Kormend,Korosladany,Koszeg,Kozarmisleny,Kunhegyes,Kunszentmarton,Kunszentmiklos,Labatlan,Lajosmizse,Lenti,Letavertes,Letenye,Lorinci,Maglod,Mako,Mandok,Marcali,Martfu,Martonvasar,Mateszalka,Melykut,Mezobereny,Mezocsat,Mezohegyes,Mezokeresztes,Mezokovacshaza,Mezokovesd,Mezotur,Mindszent,Mohacs,Monor,Mor,Morahalom,Nadudvar,Nagyatad,Nagyecsed,Nagyhalasz,Nagykallo,Nagykata,Nagykoros,Nagymaros,Nyekladhaza,Nyergesujfalu,Nyiradony,Nyirbator,Nyirmada,Nyirtelek,Ocsa,Orkeny,Oroszlany,Paks,Pannonhalma,Paszto,Pecel,Pecsvarad,Pilis,Pilisvorosvar,Polgar,Polgardi,Pomaz,Puspokladany,Pusztaszabolcs,Putnok,Racalmas,Rackeve,Rakamaz,Rakoczifalva,Sajoszentpeter,Sandorfalva,Sarbogard,Sarkad,Sarospatak,Sarvar,Satoraljaujhely,Siklos,Simontornya,Solt,Soltvadkert,Sumeg,Szabadszallas,Szarvas,Szazhalombatta,Szecseny,Szeghalom,Szendro,Szentgotthard,Szentlorinc,Szerencs,Szigethalom,Szigetvar,Szikszo,Tab,Tamasi,Tapioszele,Tapolca,Tat,Tata,Teglas,Tet,Tiszacsege,Tiszafoldvar,Tiszafured,Tiszakecske,Tiszalok,Tiszaujvaros,Tiszavasvari,Tokaj,Tokol,Tolna,Tompa,Torokbalint,Torokszentmiklos,Totkomlos,Tura,Turkeve,Ujkigyos,ujszasz,Vamospercs,Varpalota,Vasarosnameny,Vasvar,Vecses,Velence,Veresegyhaz,Verpelet,Veszto,Zahony,Zalaszentgrot,Zirc,Zsambek"},
{name: "Turkish", i: 16, min: 4, max: 10, d: "", m: 0, b: "Adapazari,Adiyaman,Afshin,Afyon,Ari,Akchaabat,Akchakale,Akchakoca,Akdamadeni,Akhisar,Aksaray,Akshehir,Alaca,Alanya,Alapli,Alashehir,Amasya,Anamur,Antakya,Ardeshen,Artvin,Aydin,Ayvalik,Babaeski,Bafra,Balikesir,Bandirma,Bartin,Bashiskele,Batman,Bayburt,Belen,Bergama,Besni,Beypazari,Beyshehir,Biga,Bilecik,Bingul,Birecik,Bismil,Bitlis,Bodrum,Bolu,Bolvadin,Bor,Bostanichi,Boyabat,Bozuyuk,Bucak,Bulancak,Bulanik,Burdur,Burhaniye,Chan,Chanakkale,Chankiri,Charshamba,Chaycuma,Chayeli,Chayirova,Cherkezkuy,Cheshme,Ceyhan,Ceylanpinar,Chine,Chivril,Cizre,Chorlu,Chumra,Dalaman,Darica,Denizli,Derik,Derince,Develi,Devrek,Didim,Dilovasi,Dinar,Diyadin,Diyarbakir,Doubayazit,Durtyol,Duzce,Duzichi,Edirne,Edremit,Elazi,Elbistan,Emirda,Erbaa,Ercish,Erdek,Erdemli,Ereli,Ergani,Erzin,Erzincan,Erzurum,Eskishehir,Fatsa,Fethiye,Gazipasha,Gebze,Gelibolu,Gerede,Geyve,Giresun,Guksun,Gulbashi,Gulcuk,Gurnen,Gumushhane,Guroymak,Hakkari,Harbiye,Havza,Hayrabolu,Hilvan,Idil,Idir,Ilgin,Imamolu,Incirliova,Inegul,Iskenderun,Iskilip,Islahiye,Isparta,Izmit,Iznik,Kadirli,Kahramanmarash,Kahta,Kaman,Kapakli,Karabuk,Karacabey,Karadeniz Ereli,Karakupru,Karaman,Karamursel,Karapinar,Karasu,Kars,Kartepe,Kastamonu,Kemer,Keshan,Kilimli,Kilis,Kirikhan,Kirikkale,Kirklareli,Kirshehir,Kiziltepe,Kurfez,Korkuteli,Kovancilar,Kozan,Kozlu,Kozluk,Kulu,Kumluca,Kurtalan,Kushadasi,Kutahya,Luleburgaz,Malatya,Malazgirt,Malkara,Manavgat,Manisa,Mardin,Marmaris,Mersin,Merzifon,Midyat,Milas,Mula,Muratli,Mush,Mut,Nazilli,Nevshehir,Nide,Niksar,Nizip,Nusaybin,udemish,Oltu,Ordu,Orhangazi,Ortaca,Osmancik,Osmaniye,Patnos,Payas,Pazarcik,Polatli,Reyhanli,Rize,Safranbolu,Salihli,Samanda,Samsun,Sandikli,shanliurfa,Saray,Sarikamish,Sarikaya,sharkishla,shereflikochhisar,Serik,Serinyol,Seydishehir,Siirt,Silifke,Silopi,Silvan,Simav,Sinop,shirnak,Sivas,Siverek,Surke,Soma,Sorgun,Suluova,Sungurlu,Suruch,Susurluk,Tarsus,Tatvan,Tavshanli,Tekirda,Terme,Tire,Tokat,Tosya,Trabzon,Tunceli,Turgutlu,Turhal,Unye,Ushak,Uzunkurpru,Van,Vezirkurpru,Viranshehir,Yahyali,Yalova,Yenishehir,Yerkury,Yozgat,Yuksekova,Zile,Zonguldak"},
{name: "Berber", i: 17, min: 4, max: 10, d: "s", m: .2, b: "Abkhouch,Adrar,Agadir,Agelmam,Aghmat,Agrakal,Agulmam,Ahaggar,Almou,Anfa,Annaba,Aousja,Arbat,Argoub,Arif,Asfi,Assamer,Assif,Azaghar,Azmour,Azrou,Beccar,Beja,Bennour,Benslimane,Berkane,Berrechid,Bizerte,Bouskoura,Boutferda,Dar Bouazza,Darallouch,Darchaabane,Dcheira,Denden,Djebel,Djedeida,Drargua,Essaouira,Ezzahra,Fas,Fnideq,Ghezeze,Goubellat,Grisaffen,Guelmim,Guercif,Hammamet,Harrouda,Hoceima,Idurar,Ifendassen,Ifoghas,Imilchil,Inezgane,Izoughar,Jendouba,Kacem,Kelibia,Kenitra,Kerrando,Khalidia,Khemisset,Khenifra,Khouribga,Kidal,Korba,Korbous,Lahraouyine,Larache,Leyun,Lqliaa,Manouba,Martil,Mazagan,Mcherga,Mdiq,Megrine,Mellal,Melloul,Midelt,Mohammedia,Mornag,Mrrakc,Nabeul,Nadhour,Nador,Nawaksut,Nefza,Ouarzazate,Ouazzane,Oued Zem,Oujda,Ouladteima,Qsentina,Rades,Rafraf,Safi,Sefrou,Sejnane,Settat,Sijilmassa,Skhirat,Slimane,Somaa,Sraghna,Susa,Tabarka,Taferka,Tafza,Tagbalut,Tagerdayt,Takelsa,Tanja,Tantan,Taourirt,Taroudant,Tasfelalayt,Tattiwin,Taza,Tazerka,Tazizawt,Tebourba,Teboursouk,Temara,Testour,Tetouan,Tibeskert,Tifelt,Tinariwen,Tinduf,Tinja,Tiznit,Toubkal,Trables,Tubqal,Tunes,Urup,Watlas,Wehran,Wejda,Youssoufia,Zaghouan,Zahret,Zemmour,Zriba"},
{name: "Arabic", i: 18, min: 4, max: 9, d: "ae", m: .2, b: "Abadilah,Abayt,Abha,Abud,Aden,Ahwar,Ajman,Alabadilah,Alabar,Alahjer,Alain,Alaraq,Alarish,Alarjam,Alashraf,Alaswaaq,Alawali,Albarar,Albawadi,Albirk,Aldhabiyah,Alduwaid,Alfareeq,Algayed,Alhada,Alhafirah,Alhamar,Alharam,Alharidhah,Alhawtah,Alhazim,Alhrateem,Alhudaydah,Alhujun,Alhuwaya,Aljahra,Aljohar,Aljubail,Alkawd,Alkhalas,Alkhawaneej,Alkhen,Alkhhafah,Alkhobar,Alkhuznah,Alkiranah,Allisafah,Allith,Almadeed,Almardamah,Almarwah,Almasnaah,Almejammah,Almojermah,Almshaykh,Almurjan,Almuwayh,Almuzaylif,Alnaheem,Alnashifah,Alqadeimah,Alqah,Alqahma,Alqalh,Alqouz,Alquaba,Alqunfudhah,Alqurayyat,Alradha,Alraqmiah,Alsadyah,Alsafa,Alshagab,Alshoqiq,Alshuqaiq,Alsilaa,Althafeer,Alwakrah,Alwasqah,Amaq,Amran,Annaseem,Aqbiyah,Arafat,Arar,Ardah,Arrawdah,Asfan,Ashayrah,Ashshahaniyah,Askar,Assaffaniyah,Ayaar,Aziziyah,Baesh,Bahrah,Baish,Balhaf,Banizayd,Baqaa,Baqal,Bidiyah,Bisha,Biyatah,Buqhayq,Burayda,Dafiyat,Damad,Dammam,Dariyah,Daynah,Dhafar,Dhahran,Dhalkut,Dhamar,Dhubab,Dhurma,Dibab,Dirab,Doha,Dukhan,Duwaibah,Enaker,Fadhla,Fahaheel,Fanateer,Farasan,Fardah,Fujairah,Ghalilah,Ghar,Ghizlan,Ghomgyah,Ghran,Hababah,Habil,Hadiyah,Haffah,Hajanbah,Hajrah,Halban,Haqqaq,Haradh,Hasar,Hathah,Hawarwar,Hawaya,Hawiyah,Hebaa,Hefar,Hijal,Husnah,Huwailat,Huwaitah,Irqah,Isharah,Ithrah,Jamalah,Jarab,Jareef,Jarwal,Jash,Jazan,Jeddah,Jiblah,Jihanah,Jilah,Jizan,Joha,Joraibah,Juban,Jubbah,Juddah,Jumeirah,Kamaran,Keyad,Khab,Khabtsaeed,Khaiybar,Khasab,Khathirah,Khawarah,Khulais,Khulays,Klayah,Kumzar,Limah,Linah,Mabar,Madrak,Mahab,Mahalah,Makhtar,Makshosh,Manfuhah,Manifah,Manshabah,Mareah,Masdar,Mashwar,Masirah,Maskar,Masliyah,Mastabah,Maysaan,Mazhar,Mdina,Meeqat,Mirbah,Mirbat,Mokhtara,Muharraq,Muladdah,Musandam,Musaykah,Muscat,Mushayrif,Musrah,Mussafah,Mutrah,Nafhan,Nahdah,Nahwa,Najran,Nakhab,Nizwa,Oman,Qadah,Qalhat,Qamrah,Qasam,Qatabah,Qawah,Qosmah,Qurain,Quraydah,Quriyat,Qurwa,Rabigh,Radaa,Rafha,Rahlah,Rakamah,Rasheedah,Rasmadrakah,Risabah,Rustaq,Ryadh,Saabah,Saabar,Sabtaljarah,Sabya,Sadad,Sadah,Safinah,Saham,Sahlat,Saihat,Salalah,Salmalzwaher,Salmiya,Sanaa,Sanaban,Sayaa,Sayyan,Shabayah,Shabwah,Shafa,Shalim,Shaqra,Sharjah,Sharkat,Sharurah,Shatifiyah,Shibam,Shidah,Shifiyah,Shihar,Shoqra,Shoqsan,Shuwaq,Sibah,Sihmah,Sinaw,Sirwah,Sohar,Suhailah,Sulaibiya,Sunbah,Tabuk,Taif,Taqah,Tarif,Tharban,Thumrait,Thuqbah,Thuwal,Tubarjal,Turaif,Turbah,Tuwaiq,Ubar,Umaljerem,Urayarah,Urwah,Wabrah,Warbah,Yabreen,Yadamah,Yafur,Yarim,Yemen,Yiyallah,Zabid,Zahwah,Zallaq,Zinjibar,Zulumah"},
{name: "Inuit", i: 19, min: 5, max: 15, d: "alutsn", m: 0, b: "Aaluik,Aappilattoq,Aasiaat,Agdleruussakasit,Aggas,Akia,Akilia,Akuliaruseq,Akuliarutsip,Akunnaaq,Agissat,Agssaussat,Alluitsup,Alluttoq,Aluit,Aluk,Ammassalik,Amarortalik,Amitsorsuaq,Anarusuk,Angisorsuaq,Anguniartarfik,Annertussoq,Annikitsoq,Anoraliuirsoq,Appat,Apparsuit,Apusiaajik,Arsivik,Arsuk,Ataa,Atammik,Ateqanngitsorsuaq,Atilissuaq,Attu,Aukarnersuaq,Augpalugtoq, Aumat,Auvilikavsak,Auvilkikavsaup,Avadtlek,Avallersuaq,Bjornesk,Blabaerdalen,Blomsterdalen,Brattalhid,Bredebrae,Brededal,Claushavn,Edderfulegoer,Egger,Eqalugalinnguit,Eqalugarssuit,Eqaluit,Eqqua,Etah,Graah,Hakluyt,Haredalen,Hareoen,Hundeo,Igdlorssuit,Igaliku,Igdlugdlip,Igdluluarssuk,Iginniafik,Ikamiuk,Ikamiut,Ikarissat,Ikateq,Ikeq,Ikerasak,Ikerasaarsuk,Ikermiut,Ikermoissuaq,Ikertivaq,Ikorfarssuit,Ikorfat,Ilimanaq,Illorsuit,Iluileq,Iluiteq,Ilulissat,Illunnguit,Imaarsivik,Imartunarssuk,Immikkoortukajik,Innaarsuit,Ingjald,Inneruulalik,Inussullissuaq,Iqek,Ikerasakassak,Iperaq,Ippik,Isortok,Isungartussoq,Itileq,Itivdleq,Itissaalik,Ittit,Ittoqqortoormiit,Ivingmiut,Ivittuut,Kanajoorartuut,Kangaamiut,Kangaarsuk,Kangaatsiaq,Kangeq,Kangerluk,Kangerlussuaq,Kanglinnguit,Kapisillit,Karrat,Kekertamiut,Kiatak,Kiatassuaq,Kiataussaq,Kigatak,Kigdlussat,Kinaussak,Kingittorsuaq,Kitak,Kitsissuarsuit,Kitsissut,Klenczner,Kook,Kraulshavn,Kujalleq,Kullorsuaq,Kulusuk,Kuurmiit,Kuusuaq,Laksedalen,Maniitsoq,Marrakajik,Mattaangassut,Mernoq,Mittivakkat,Moriusaq,Myggbukta,Naajaat,Nako,Nangissat,Nanortalik,Nanuuseq,Nappassoq,Narsarmijt,Narssaq,Narsarsuaq,Narssarssuk,Nasaussaq,Nasiffik,Natsiarsiorfik,Naujanguit,Niaqornaarsuk,Niaqornat,Nordfjordspasset,Nugatsiaq,Nuluuk,Nunaa,Nunarssit,Nunarsuaq,Nunataaq,Nunatakavsaup,Nutaarmiut,Nuugaatsiaq,Nuuk,Nuukullak,Nuuluk,Nuussuaq,Olonkinbyen,Oqaatsut,Oqaitsúnguit,Oqonermiut,Oodaaq,Paagussat,Palungataq,Pamialluk,Paamiut,Paatuut,Patuersoq,Perserajoq,Paornivik,Pituffik,Puugutaa,Puulkuip,Qaanaq,Qaarsorsuaq,Qaarsorsuatsiaq,Qaasuitsup,Qaersut,Qajartalik,Qallunaat,Qaneq,Qaqaarissorsuaq,Qaqit,Qaqortok,Qasigiannguit,Qasse,Qassimiut,Qeertartivaq,Qeertartivatsiaq,Qeqertaq,Qeqertarssdaq,Qeqertarsuaq,Qeqertasussuk,Qeqertarsuatsiaat,Qeqertat,Qeqqata,Qernertoq,Qernertunnguit,Qianarreq,Qilalugkiarfik,Qingagssat,Qingaq,Qoornuup,Qorlortorsuaq,Qullikorsuit,Qunnerit,Qutdleq,Ravnedalen,Ritenbenk,Rypedalen,Sarfannguit,Saarlia,Saarloq,Saatoq,Saatorsuaq,Saatup,Saattut,Sadeloe,Salleq,Salliaruseq,Sammeqqat,Sammisoq,Sanningassoq,Saqqaq,Saqqarlersuaq,Saqqarliit,Sarqaq,Sattiaatteq,Savissivik,Serfanguaq,Sermersooq,Sermersut,Sermilik,Sermiligaaq,Sermitsiaq,Simitakaja,Simiutaq,Singamaq,Siorapaluk,Sisimiut,Sisuarsuit,Skal,Skarvefjeld,Skjoldungen,Storoen,Sullorsuaq,Suunikajik,Sverdrup,Taartoq,Takiseeq,Talerua,Tarqo,Tasirliaq,Tasiusak,Tiilerilaaq,Timilersua,Timmiarmiut,Tingmjarmiut,Traill,Tukingassoq,Tuttorqortooq,Tuujuk,Tuttulissuup,Tussaaq,Uigordlit,Uigorlersuaq,Uilortussoq,Uiivaq,Ujuaakajiip,Ukkusissat,Umanat,Upernavik,Upernattivik,Upepnagssivik,Upernivik,Uttorsiutit,Uumannaq,Uummannaarsuk,Uunartoq,Uvkusigssat,Ymer"},
{name: "Basque", i: 20, min: 4, max: 11, d: "r", m: .1, b: "Abadio,Abaltzisketa,Abanto Zierbena,Aduna,Agurain,Aia,Aiara,Aizarnazabal,Ajangiz,Albiztur,Alegia,Alkiza,Alonsotegi,Altzaga,Altzo,Amezketa,Amorebieta,Amoroto,Amurrio,Andoain,Anoeta,Antzuola,Arakaldo,Arama,Aramaio,Arantzazu,Arbatzegi ,Areatza,Aretxabaleta,Arraia,Arrankudiaga,Arrasate,Arratzu,Arratzua,Arrieta,Arrigorriaga,Artea,Artzentales,Artziniega,Asparrena,Asteasu,Astigarraga,Ataun,Atxondo,Aulesti,Azkoitia,Azpeitia,Bakio,Baliarrain,Balmaseda,Barakaldo,Barrika,Barrundia,Basauri,Bastida,Beasain,Bedia,Beizama,Belauntza,Berango,Berantevilla,Berastegi,Bergara,Bermeo,Bernedo,Berriatua,Berriz,Berrobi,Bidania,Bilar,Bilbao,Burgelu,Busturia,Deba,Derio,Dima,Donemiliaga,Donostia,Dulantzi,Durango,Ea,Eibar,Elantxobe,Elduain,Elgeta,Elgoibar,Elorrio,Erandio,Ereno,Ermua,Errenteria,Errezil,Erribera Beitia,Erriberagoitia,Errigoiti,Eskoriatza,Eskuernaga,Etxebarri,Etxebarria,Ezkio,Fika,Forua,Fruiz,Gabiria,Gaintza,Galdakao,Galdames,Gamiz,Garai,Gasteiz,Gatika,Gatzaga,Gaubea,Gauna,Gautegiz Arteaga,Gaztelu,Gernika,Gerrikaitz,Getaria,Getxo,Gizaburuaga,Goiatz,Gordexola,Gorliz,Harana,Hernani,Hernialde,Hondarribia,Ibarra,Ibarrangelu,Idiazabal,Iekora,Igorre,Ikaztegieta,Iruna Oka,Irun,Irura,Iruraiz,Ispaster,Itsaso,Itsasondo,Iurreta,Izurtza,Jatabe,Kanpezu,Karrantza Harana,Kortezubi,Kripan,Kuartango,Lanestosa,Lantziego,Larrabetzu,Larraul,Lasarte,Laudio,Laukiz,Lazkao,Leaburu,Legazpi,Legorreta,Legutio,Leintz,Leioa,Lekeitio,Lemoa,Lemoiz,Leza,Lezama,Lezo,Lizartza,Loiu,Lumo,Manaria,Maeztu,Mallabia,Markina,Maruri,Manueta,Menaka,Mendaro,Mendata,Mendexa,Moreda Araba,Morga,Mundaka,Mungia,Munitibar,Murueta,Muskiz,Mutiloa,Mutriku,Muxika,Nabarniz,Onati,Oiartzun,Oion,Okondo,Olaberria,Ondarroa,Ordizia,Orendain,Orexa,Oria,Orio,Ormaiztegi,Orozko,Ortuella,Otxandio,Pasaia,Plentzia,Portugalete,Samaniego,Santurtzi,Segura,Sestao,Sondika,Sopela,Sopuerta,Soraluze,Sukarrieta,Tolosa,Trapagaran,Turtzioz,Ubarrundia,Ubide,Ugao,Urdua,Urduliz,Urizaharra,Urkabustaiz,Urnieta,Urretxu,Usurbil,Xemein,Zaia,Zaldibar,Zaldibia,Zalduondo,Zambrana,Zamudio,Zaratamo,Zarautz,Zeanuri,Zeberio,Zegama,Zerain,Zestoa,Zierbena,Zigoitia,Ziortza,Zizurkil,Zuia,Zumaia,Zumarraga"},
{name: "Nigerian", i: 21, min: 4, max: 10, d: "", m: .3, b: "Abadogo,Abafon,Abdu,Acharu,Adaba,Adealesu,Adeto,Adyongo,Afaga,Afamju,Afuje,Agbelagba,Agigbigi,Agogoke,Ahute,Aiyelaboro,Ajebe,Ajola,Akarekwu,Akessan,Akunuba,Alawode,Alkaijji,Amangam,Amaoji,Amgbaye,Amtasa,Amunigun,Anase,Aniho,Animahun,Antul,Anyoko,Apekaa,Arapagi,Asamagidi,Asande,Ataibang,Awgbagba,Awhum,Awodu,Babanana,Babateduwa,Bagu,Bakura,Bandakwai,Bangdi,Barbo,Barkeje,Basa,Basabra,Basansagawa,Bieleshin,Bilikani,Birnindodo,Braidu,Bulakawa,Buriburi,Burisidna,Busum,Bwoi,Cainnan,Chakum,Charati,Chondugh,Dabibikiri,Dagwarga,Dallok,Danalili,Dandala,Darpi,Dhayaki,Dokatofa,Doma,Dozere,Duci,Dugan,Ebelibri,Efem,Efoi,Egudu,Egundugbo,Ekoku,Ekpe,Ekwere,Erhua,Eteu,Etikagbene,Ewhoeviri,Ewhotie,Ezemaowa,Fatima,Gadege,Galakura,Galea,Gamai,Gamen,Ganjin,Gantetudu,Garangamawa,Garema,Gargar,Gari,Garinbode,Garkuwa,Garu Kime,Gazabu,Gbure,Gerti,Gidan,Giringwe,Gitabaremu,Giyagiri,Giyawa,Gmawa,Golakochi,Golumba,Guchi,Gudugu,Gunji,Gusa,Gwambula,Gwamgwam,Gwodoti,Hayinlere,Hayinmaialewa,Hirishi,Hombo,Ibefum,Iberekodo,Ibodeipa,Icharge,Ideoro,Idofin,Idofinoka,Idya,Iganmeji,Igbetar,Igbogo,Ijoko,Ijuwa,Ikawga,Ikekogbe,Ikhin,Ikoro,Ikotefe,Ikotokpora,Ikpakidout,Ikpeoniong,Ilofa,Imuogo,Inyeneke,Iorsugh,Ipawo,Ipinlerere,Isicha,Itakpa,Itoki,Iyedeame,Jameri,Jangi,Jara,Jare,Jataudakum,Jaurogomki,Jepel,Jibam,Jirgu,Jirkange,Kafinmalama,Kamkem,Katab,Katanga,Katinda,Katirije,Kaurakimba,Keffinshanu,Kellumiri,Kiagbodor,Kibiare,Kingking,Kirbutu,Kita,Kogbo,Kogogo,Kopje,Koriga,Koroko,Korokorosei,Kotoku,Kuata,Kujum,Kukau,Kunboon,Kuonubogbene,Kurawe,Kushinahu,Kwaramakeri,Ladimeji,Lafiaro,Lahaga,Laindebajanle,Laindegoro,Lajere,Lakati,Ligeri,Litenswa,Lokobimagaji,Lusabe,Maba,Madarzai,Magoi,Maialewa,Maianita,Maijuja,Mairakuni,Maleh,Malikansaa,Mallamkola,Mallammaduri,Marmara,Masagu,Masoma,Mata,Matankali,Mbalare,Megoyo,Meku,Miama,Mige,Mkporagwu,Modi,Molafa,Mshi,Msugh,Muduvu,Murnachehu,Namnai,Nanumawa,Nasudu,Ndagawo,Ndamanma,Ndiebeleagu,Ndiwulunbe,Ndonutim,Ngaruwa,Ngbande,Nguengu,Nto Ekpe,Nubudi,Nyajo,Nyido,Nyior,Obafor,Obazuwa,Odajie,Odiama,Ofunatam,Ogali,Ogan,Ogbaga,Ogbahu,Ogultu,Ogunbunmi,Ogunmakin,Ojaota,Ojirami,Ojopode,Okehin,Olugunna,Omotunde,Onipede,Onisopi,Onma,Orhere,Orya,Oshotan,Otukwang,Otunade,Pepegbene,Poros,Rafin,Rampa,Rimi,Rinjim,Robertkiri,Rugan,Rumbukawa,Sabiu,Sabon,Sabongari,Sai,Salmatappare,Sangabama,Sarabe,Seboregetore,Seibiri,Sendowa,Shafar,Shagwa,Shata,Shefunda,Shengu,Sokoron,Sunnayu,Taberlma,Tafoki,Takula,Talontan,Taraku,Tarhemba,Tayu,Ter,Timtim,Timyam,Tindirke,Tirkalou,Tokunbo,Tonga,Torlwam,Tseakaadza,Tseanongo,Tseavungu,Tsebeeve,Tsekov,Tsepaegh,Tuba,Tumbo,Tungalombo,Tungamasu,Tunganrati,Tunganyakwe,Tungenzuri,Ubimimi,Uhkirhi,Umoru,Umuabai,Umuaja,Umuajuju,Umuimo,Umuojala,Unchida,Ungua,Unguwar,Unongo,Usha,Ute,Utongbo,Vembera,Vorokotok,Wachin,Walebaga,Wurawura,Wuro,Yanbashi,Yanmedi,Yenaka,Yoku,Zamangera,Zarunkwari,Zilumo,Zulika"},
{name: "Celtic", i: 22, min: 4, max: 12, d: "nld", m: 0, b: "Aberaman,Aberangell,Aberarth,Aberavon,Aberbanc,Aberbargoed,Aberbeeg,Abercanaid,Abercarn,Abercastle,Abercegir,Abercraf,Abercregan,Abercych,Abercynon,Aberdare,Aberdaron,Aberdaugleddau,Aberdeen,Aberdulais,Aberdyfi,Aberedw,Abereiddy,Abererch,Abereron,Aberfan,Aberffraw,Aberffrwd,Abergavenny,Abergele,Aberglasslyn,Abergorlech,Abergwaun,Abergwesyn,Abergwili,Abergwynfi,Abergwyngregyn,Abergynolwyn,Aberhafesp,Aberhonddu,Aberkenfig,Aberllefenni,Abermain,Abermaw,Abermorddu,Abermule,Abernant,Aberpennar,Aberporth,Aberriw,Abersoch,Abersychan,Abertawe,Aberteifi,Aberthin,Abertillery,Abertridwr,Aberystwyth,Achininver,Afonhafren,Alisaha,Antinbhearmor,Ardenna,Attacon,Beira,Bhrura,Boioduro,Bona,Boudobriga,Bravon,Brigant,Briganta,Briva,Cambodunum,Cambra,Caracta,Catumagos,Centobriga,Ceredigion,Chalain,Dinn,Diwa,Dubingen,Duro,Ebora,Ebruac,Eburodunum,Eccles,Eighe,Eireann,Ferkunos,Genua,Ghrainnse,Inbhear,Inbhir,Inbhirair,Innerleithen,Innerleven,Innerwick,Inver,Inveraldie,Inverallan,Inveralmond,Inveramsay,Inveran,Inveraray,Inverarnan,Inverbervie,Inverclyde,Inverell,Inveresk,Inverfarigaig,Invergarry,Invergordon,Invergowrie,Inverhaddon,Inverkeilor,Inverkeithing,Inverkeithney,Inverkip,Inverleigh,Inverleith,Inverloch,Inverlochlarig,Inverlochy,Invermay,Invermoriston,Inverness,Inveroran,Invershin,Inversnaid,Invertrossachs,Inverugie,Inveruglas,Inverurie,Kilninver,Kirkcaldy,Kirkintilloch,Krake,Latense,Leming,Lindomagos,Llanaber,Lochinver,Lugduno,Magoduro,Monmouthshire,Narann,Novioduno,Nowijonago,Octoduron,Penning,Pheofharain,Ricomago,Rossinver,Salodurum,Seguia,Sentica,Theorsa,Uige,Vitodurum,Windobona"},
{name: "Mesopotamian", i: 23, min: 4, max: 9, d: "srpl", m: .1, b: "Adab,Akkad,Akshak,Amnanum,Arbid,Arpachiyah,Arrapha,Assur,Babilim,Badtibira,Balawat,Barsip,Borsippa,Carchemish,Chagar Bazar,Chuera,Ctesiphon ,Der,Dilbat,Diniktum,Doura,Durkurigalzu,Ekallatum,Emar,Erbil,Eridu,Eshnunn,Fakhariya ,Gawra,Girsu,Hadatu,Hamoukar,Haradum,Harran,Hatra,Idu,Irisagrig,Isin,Jemdet,Kahat,Kartukulti,Khaiber,Kish ,Kisurra,Kuara,Kutha,Lagash,Larsa ,Leilan,Marad,Mardaman,Mari,Mashkan,Mumbaqat ,Nabada,Nagar,Nerebtum,Nimrud,Nineveh,Nippur,Nuzi,Qalatjarmo,Qatara,Rawda,Seleucia,Shaduppum,Shanidar,Sharrukin,Shemshara,Shibaniba,Shuruppak,Sippar,Tarbisu,Tellagrab,Tellessawwan,Tellessweyhat,Tellhassuna,Telltaya,Telul,Terqa,Thalathat,Tutub,Ubaid ,Umma,Ur,Urfa,Urkesh,Uruk,Urum,Zabalam,Zenobia"},
{name: "Iranian", i: 24, min: 5, max: 11, d: "", m: .1, b: "Abali,Abrisham,Absard,Abuzeydabad,Afus,Alavicheh,Alikosh,Amol,Anarak,Anbar,Andisheh,Anshan,Aran,Ardabil,Arderica,Ardestan,Arjomand,Asgaran,Asgharabad,Ashian,Awan,Babajan,Badrud,Bafran,Baghestan,Baghshad,Bahadoran,Baharan Shahr,Baharestan,Bakun,Bam,Baqershahr,Barzok,Bastam,Behistun,Bitistar,Bumahen,Bushehr,Chadegan,Chahardangeh,Chamgardan,Chermahin,Choghabonut,Chugan,Damaneh,Damavand,Darabgard,Daran,Dastgerd,Dehaq,Dehaqan,Dezful,Dizicheh,Dorcheh,Dowlatabad,Duruntash,Ecbatana,Eslamshahr,Estakhr,Ezhiyeh,Falavarjan,Farrokhi,Fasham,Ferdowsieh,Fereydunshahr,Ferunabad,Firuzkuh,Fuladshahr,Ganjdareh,Ganzak,Gaz,Geoy,Godin,Goldasht,Golestan,Golpayegan,Golshahr,Golshan,Gorgab,Guged,Habibabad,Hafshejan,Hajjifiruz,Hana,Harand,Hasanabad,Hasanlu,Hashtgerd,Hecatompylos,Hormirzad,Imanshahr,Isfahan,Jandaq,Javadabad,Jiroft,Jowsheqan ,Jowzdan,Kabnak,Kahriz Sang,Kahrizak,Kangavar,Karaj,Karkevand,Kashan,Kelishad,Kermanshah,Khaledabad,Khansar,Khorramabad,Khur,Khvorzuq,Kilan,Komeh,Komeshcheh,Konar,Kuhpayeh,Kul,Kushk,Lavasan,Laybid,Liyan,Lyan,Mahabad,Mahallat,Majlesi,Malard,Manzariyeh,Marlik,Meshkat,Meymeh,Miandasht,Mish,Mobarakeh,Nahavand,Nain,Najafabad,Naqshe,Narezzash,Nasimshahr,Nasirshahr,Nasrabad,Natanz,Neyasar,Nikabad,Nimvar,Nushabad,Pakdasht,Parand,Pardis,Parsa,Pasargadai,Patigrabana,Pir Bakran,Pishva,Qahderijan,Qahjaverestan,Qamsar,Qarchak,Qods,Rabat,Ray-shahr,Rezvanshahr,Rhages,Robat Karim,Rozveh,Rudehen,Sabashahr,Safadasht,Sagzi,Salehieh,Sandal,Sarvestan,Sedeh,Sefidshahr,Semirom,Semnan,Shadpurabad,Shah,Shahdad,Shahedshahr,Shahin,Shahpour,Shahr,Shahreza,Shahriar,Sharifabad,Shemshak,Shiraz,Shushan,Shushtar,Sialk,Sin,Sukhteh,Tabas,Tabriz,Takhte,Talkhuncheh,Talli,Tarq,Temukan,Tepe,Tiran,Tudeshk,Tureng,Urmia,Vahidieh,Vahrkana,Vanak,Varamin,Varnamkhast,Varzaneh,Vazvan,Yahya,Yarim,Yasuj,Zarrin Shahr,Zavareh,Zayandeh,Zazeran,Ziar,Zibashahr,Zranka"},
{name: "Hawaiian", i: 25, min: 5, max: 10, d: "auo", m: 1, b: "Aapueo,Ahoa,Ahuakaio,Ahuakamalii,Ahuakeio,Ahupau,Aki,Alaakua,Alae,Alaeloa,Alaenui,Alamihi,Aleamai,Alena,Alio,Aupokopoko,Auwahi,Hahakea,Haiku,Halakaa,Halehaku,Halehana,Halemano,Haleu,Haliimaile,Hamakuapoko,Hamoa,Hanakaoo,Hanaulu,Hanawana,Hanehoi,Haneoo,Haou,Hikiaupea,Hoalua,Hokuula,Honohina,Honokahua,Honokala,Honokalani,Honokeana,Honokohau,Honokowai,Honolua,Honolulu,Honolulunui,Honomaele,Honomanu,Hononana,Honopou,Hoolawa,Hopenui,Hualele,Huelo,Hulaia,Ihuula,Ilikahi,Kaalaea,Kaalelehinale,Kaapahu,Kaehoeho,Kaeleku,Kaeo,Kahakuloa,Kahalawe,Kahalawe,Kahalehili,Kahana,Kahilo,Kahuai,Kaiaula,Kailihiakoko,Kailua,Kainehe,Kakalahale,Kakanoni,Kakio,Kakiweka,Kalena,Kalenanui,Kaleoaihe,Kalepa,Kaliae,Kalialinui,Kalihi,Kalihi,Kalihi,Kalimaohe,Kaloi,Kamani,Kamaole,Kamehame,Kanahena,Kanaio,Kaniaula,Kaonoulu,Kaopa,Kapaloa,Kapaula,Kapewakua,Kapohue,Kapuaikini,Kapunakea,Kapuuomahuka,Kauau,Kauaula,Kaukuhalahala,Kaulalo,Kaulanamoa,Kauluohana,Kaumahalua,Kaumakani,Kaumanu,Kaunauhane,Kaunuahane,Kaupakulua,Kawaipapa,Kawaloa,Kawaloa,Kawalua,Kawela,Keaa,Keaalii,Keaaula,Keahua,Keahuapono,Keakuapauaela,Kealahou,Keanae,Keauhou,Kekuapawela,Kelawea,Keokea,Keopuka,Kepio,Kihapuhala,Kikoo,Kilolani,Kipapa,Koakupuna,Koali,Koananai,Koheo,Kolea,Kolokolo,Kooka,Kopili,Kou,Kualapa,Kuhiwa,Kuholilea,Kuhua,Kuia,Kuiaha,Kuikui,Kukoae,Kukohia,Kukuiaeo,Kukuioolu,Kukuipuka,Kukuiula,Kulahuhu,Kumunui,Lapakea,Lapalapaiki,Lapueo,Launiupoko,Loiloa,Lole,Lualailua,Maalo,Mahinahina,Mahulua,Maiana,Mailepai,Makaakini,Makaalae,Makaehu,Makaiwa,Makaliua,Makapipi,Makapuu,Makawao,Makila,Mala,Maluaka,Mamalu,Manawaiapiki,Manawainui,Maulili,Mehamenui,Miana,Mikimiki,Moalii,Moanui,Mohopili,Mohopilo,Mokae,Mokuia,Mokupapa,Mooiki,Mooloa,Moomuku,Muolea,Nahuakamalii,Nailiilipoko,Nakaaha,Nakalepo,Nakaohu,Nakapehu,Nakula,Napili,Niniau,Niumalu,Nuu,Ohia,Oloewa,Olowalu,Omaopio,Onau,Onouli,Opaeula,Opana,Opikoula,Paakea,Paeahu,Paehala,Paeohi,Pahoa,Paia,Pakakia,Pakala,Palauea,Palemo,Panaewa,Paniau,Papaaea,Papaanui,Papaauhau,Papahawahawa,Papaka,Papauluana,Pauku,Paunau,Pauwalu,Pauwela,Peahi,Piapia,Pohakanele,Pohoula,Polaiki,Polanui,Polapola,Polua,Poopoo,Popoiwi,Popoloa,Poponui,Poupouwela,Puaa,Puaaluu,Puahoowali,Puakea,Puako,Pualaea,Puehuehu,Puekahi,Pueokauiki,Pukaauhuhu,Pukalani,Pukuilua,Pulehu,Pulehuiki,Pulehunui,Punaluu,Puolua,Puou,Puuhaehae,Puuhaoa,Puuiki,Puuki,Puukohola,Puulani,Puumaneoneo,Puunau,Puunoa,Puuomaiai,Puuomaile,Uaoa,Uhao,Ukumehame,Ulaino,Ulumalu,Wahikuli,Waiahole,Waiakoa,Waianae,Waianu,Waiawa,Waiehu,Waieli,Waihee,Waikapu,Wailamoa,Wailaulau,Wailua,Wailuku,Wainee,Waiohole,Waiohonu,Waiohue,Waiohuli,Waiokama,Waiokila,Waiopai,Waiopua,Waipao,Waipio,Waipioiki,Waipionui,Waipouli,Wakiu,Wananalua"},
{name: "Karnataka", i: 26, min: 5, max: 11, d: "tnl", m: 0, b: "Adityapatna,Adyar,Afzalpur,Aland,Alnavar,Alur,Ambikanagara,Anekal,Ankola,Annigeri,Arkalgud,Arsikere,Athni,Aurad,Badami,Bagalkot,Bagepalli,Bail,Bajpe,Bangalore,Bangarapet,Bankapura,Bannur,Bantval,Basavakalyan,Basavana,Belgaum,Beltangadi,Belur,Bhadravati,Bhalki,Bhatkal,Bhimarayanagudi,Bidar,Bijapur,Bilgi,Birur,Bommasandra,Byadgi,Challakere,Chamarajanagar,Channagiri,Channapatna,Channarayapatna,Chik,Chikmagalur,Chiknayakanhalli,Chikodi,Chincholi,Chintamani,Chitapur,Chitgoppa,Chitradurga,Dandeli,Dargajogihalli,Devadurga,Devanahalli,Dod,Donimalai,Gadag,Gajendragarh,Gangawati,Gauribidanur,Gokak,Gonikoppal,Gubbi,Gudibanda,Gulbarga,Guledgudda,Gundlupet,Gurmatkal,Haliyal,Hangal,Harapanahalli,Harihar,Hassan,Hatti,Haveri,Hebbagodi,Heggadadevankote,Hirekerur,Holalkere,Hole,Homnabad,Honavar,Honnali,Hoovina,Hosakote,Hosanagara,Hosdurga,Hospet,Hubli,Hukeri,Hungund,Hunsur,Ilkal,Indi,Jagalur,Jamkhandi,Jevargi,Jog,Kadigenahalli,Kadur,Kalghatgi,Kamalapuram,Kampli,Kanakapura,Karkal,Karwar,Khanapur,Kodiyal,Kolar,Kollegal,Konnur,Koppa,Koppal,Koratagere,Kotturu,Krishnarajanagara,Krishnarajasagara,Krishnarajpet,Kudchi,Kudligi,Kudremukh,Kumta,Kundapura,Kundgol,Kunigal,Kurgunta,Kushalnagar,Kushtagi,Lakshmeshwar,Lingsugur,Londa,Maddur,Madhugiri,Madikeri,Mahalingpur,Malavalli,Mallar,Malur,Mandya,Mangalore,Manvi,Molakalmuru,Mudalgi,Mudbidri,Muddebihal,Mudgal,Mudhol,Mudigere,Mulbagal,Mulgund,Mulki,Mulur,Mundargi,Mundgod,Munirabad,Mysore,Nagamangala,Nanjangud,Narasimharajapura,Naregal,Nargund,Navalgund,Nipani,Pandavapura,Pavagada,Piriyapatna,Pudu,Puttur,Rabkavi,Raichur,Ramanagaram,Ramdurg,Ranibennur,Raybag,Robertson,Ron,Sadalgi,Sagar,Sakleshpur,Saligram,Sandur,Sankeshwar,Saundatti,Savanur,Sedam,Shahabad,Shahpur,Shaktinagar,Shiggaon,Shikarpur,Shirhatti,Shorapur,Shrirangapattana,Siddapur,Sidlaghatta,Sindgi,Sindhnur,Sira,Siralkoppa,Sirsi,Siruguppa,Somvarpet,Sorab,Sringeri,Srinivaspur,Sulya,Talikota,Tarikere,Tekkalakote,Terdal,Thumbe,Tiptur,Tirthahalli,Tirumakudal,Tumkur,Turuvekere,Udupi,Vijayapura,Wadi,Yadgir,Yelandur,Yelbarga,Yellapur,Yenagudde"},
{name: "Quechua", i: 27, min: 6, max: 12, d: "l", m: 0, b: "Altomisayoq,Ancash,Andahuaylas,Apachekta,Apachita,Apu ,Apurimac,Arequipa,Atahuallpa,Atawalpa,Atico,Ayacucho,Ayllu,Cajamarca,Carhuac,Carhuacatac,Cashan,Caullaraju,Caxamalca,Cayesh,Chacchapunta,Chacraraju,Champara,Chanchan,Chekiacraju,Chinchey,Chontah,Chopicalqui,Chucuito,Chuito,Chullo,Chumpi,Chuncho,Chuquiapo,Churup,Cochapata,Cojup,Collota,Conococha,Copa,Corihuayrachina,Cusichaca,Despacho,Haika,Hanpiq,Hatun,Haywarisqa,Huaca,Hualcan,Huamanga,Huamashraju,Huancarhuas,Huandoy,Huantsan,Huarmihuanusca,Huascaran,Huaylas,Huayllabamba,Huichajanca,Huinayhuayna,Huinioch,Illiasca,Intipunku,Ishinca,Jahuacocha,Jirishanca,Juli,Jurau,Kakananpunta,Kamasqa,Karpay,Kausay,Khuya ,Kuelap,Llaca,Llactapata,Llanganuco,Llaqta,Llupachayoc,Machu,Mallku,Matarraju,Mikhuy,Milluacocha,Munay,Ocshapalca,Ollantaytambo,Pacamayo,Paccharaju,Pachacamac,Pachakamaq,Pachakuteq,Pachakuti,Pachamama  ,Paititi,Pajaten,Palcaraju,Pampa,Panaka,Paqarina,Paqo,Parap,Paria,Patallacta,Phuyupatamarca,Pisac,Pongos,Pucahirca,Pucaranra,Puscanturpa,Putaca,Qawaq ,Qayqa,Qochamoqo,Qollana,Qorihuayrachina,Qorimoqo,Quenuaracra,Queshque,Quillcayhuanca,Quillya,Quitaracsa,Quitaraju,Qusqu,Rajucolta,Rajutakanan,Rajutuna,Ranrahirca,Ranrapalca,Raria,Rasac,Rimarima,Riobamba,Runkuracay,Rurec,Sacsa,Saiwa,Sarapo,Sayacmarca,Sinakara,TamboColorado,Tamboccocha,Taripaypacha,Taulliraju,Tawantinsuyu,Taytanchis,Tiwanaku,Tocllaraju,Tsacra,Tuco,Tullparaju,Tumbes,Ulta,Uruashraju,Vallunaraju,Vilcabamba,Wacho ,Wankawillka,Wayra,Yachay,Yahuarraju,Yanamarey,Yanesha,Yerupaja"},
{name: "Swahili", i: 28, min: 4, max: 9, d: "", m: 0, b: "Abim,Adjumani,Alebtong,Amolatar,Amuria,Amuru,Apac,Arua,Arusha,Babati,Baragoi,Bombo,Budaka,Bugembe,Bugiri,Buikwe,Bukedea,Bukoba,Bukomansimbi,Bukungu,Buliisa,Bundibugyo,Bungoma,Busembatya,Bushenyi,Busia,Busia,Busolwe,Butaleja,Butambala,Butere,Buwenge,Buyende,Dadaab,Dodoma,Dokolo,Eldoret,Elegu,Emali,Embu,Entebbe,Garissa,Gede,Gulu,Handeni,Hima,Hoima,Hola,Ibanda,Iganga,Iringa,Isingiro,Isiolo,Jinja,Kaabong,Kabale,Kaberamaido,Kabuyanda,Kabwohe,Kagadi,Kahama,Kajiado,Kakamega,Kakinga,Kakira,Kakiri,Kakuma,Kalangala,Kaliro,Kalisizo,Kalongo,Kalungu,Kampala,Kamuli,Kamwenge,Kanoni,Kanungu,Kapchorwa,Kapenguria,Kasese,Kasulu,Katakwi,Kayunga,Kericho,Keroka,Kiambu,Kibaale,Kibaha,Kibingo,Kiboga,Kibwezi,Kigoma,Kihiihi,Kilifi,Kira,Kiruhura,Kiryandongo,Kisii,Kisoro,Kisumu,Kitale,Kitgum,Kitui,Koboko,Korogwe,Kotido,Kumi,Kyazanga,Kyegegwa,Kyenjojo,Kyotera,Lamu,Langata,Lindi,Lodwar,Lokichoggio,Londiani,Loyangalani,Lugazi,Lukaya,Luweero,Lwakhakha,Lwengo,Lyantonde,Machakos,Mafinga,Makambako,Makindu,Malaba,Malindi,Manafwa,Mandera,Maralal,Marsabit,Masaka,Masindi,MasindiPort,Masulita,Matugga,Mayuge,Mbale,Mbarara,Mbeya,Meru,Mitooma,Mityana,Mombasa,Morogoro,Moroto,Moshi,Moyale,Moyo,Mpanda,Mpigi,Mpondwe,Mtwara,Mubende,Mukono,Mumias,Muranga,Musoma,Mutomo,Mutukula,Mwanza,Nagongera,Nairobi,Naivasha,Nakapiripirit,Nakaseke,Nakasongola,Nakuru,Namanga,Namayingo,Namutumba,Nansana,Nanyuki,Narok,Naromoru,Nebbi,Ngora,Njeru,Njombe,Nkokonjeru,Ntungamo,Nyahururu,Nyeri,Oyam,Pader,Paidha,Pakwach,Pallisa,Rakai,Ruiru,Rukungiri,Rwimi,Sanga,Sembabule,Shimoni,Shinyanga,Singida,Sironko,Songea,Soroti,Ssabagabo,Sumbawanga,Tabora,Takaungu,Tanga,Thika,Tororo,Tunduma,Vihiga,Voi,Wajir,Wakiso,Watamu,Webuye,Wobulenzi,Wote,Wundanyi,Yumbe,Zanzibar"},
{name: "Vietnamese", i: 29, min: 3, max: 12, d: "", m: 1, b: "An Khe,An Nhon,Ayun Pa,Ba Don,Ba Ria,Bac Giang,Bac Kan,Bac Lieu,Bac Ninh,Bao Loc,Ben Cat,Ben Tre,Bien Hoa,Bim Son,Binh Long,Binh Minh,Buon Ho,Buon Ma Thuot,Ca Mau,Cai Lay,Cam Pha,Cam Ranh,Can Tho,Cao Bang,Cao Lanh,Chau Doc,Chi Linh,Cua Lo,Da Lat,Da Nang,Di An,Dien Ban,Dien Bien Phu,Dong Ha,Dong Hoi,Dong Trieu,Duyen Hai,Gia Nghia,Gia Rai,Go Cong,Ha Giang,Ha Long,Ha Noi,Ha Tinh,Hai Duong,Hai Phong,Hoa Binh,Hoang Mai,Hoi An,Hong Linh,Hong Ngu,Hue,Hung Yen,Huong Thuy,Huong Tra,Kien Tuong,Kon Tum,Ky Anh,La Gi,Lai Chau,Lang Son,Lao Cai,Long Khanh,Long My,Long Xuyen,Mong Cai,Muong Lay,My Hao,My Tho,Nam Dinh,Nga Bay,Nga Nam,Nghia Lo,Nha Trang,Ninh Binh,Ninh Hoa,Phan Rang Thap Cham,Phan Thiet,Pho Yen,Phu Ly,Phu My,Phu Tho,Phuoc Long,Pleiku,Quang Ngai,Quang Tri,Quang Yen,Quy Nhon,Rach Gia,Sa Dec,Sam Son,Soc Trang,Son La,Son Tay,Song Cau,Song Cong,Tam Diep,Tam Ky,Tan An,Tan Chau,Tan Uyen,Tay Ninh,Thai Binh,Thai Hoa,Thai Nguyen,Thanh Hoa,Thu Dau Mot,Thuan An,Tra Vinh,Tu Son,Tuy Hoa,Tuyen Quang,Uong Bi,Vi Thanh,Viet Tri,Vinh,Vinh Chau,Vinh Long,Vinh Yen,Vung Tau,Yen Bai"},
{name: "Cantonese", i: 30, min: 5, max: 11, d: "", m: 0, b: "Chaiwan,Chekham,Cheungshawan,Chingchung,Chinghoi,Chingsen,Chingshing,Chiunam,Chiuon,Chiuyeung,Chiyuen,Choihung,Chuehoi,Chuiman,Chungfa,Chungfu,Chungsan,Chunguktsuen,Dakhing,Daopo,Daumun,Dingwu,Dinpak,Donggun,Dongyuen,Duenchau,Fachau,Fado,Fanling,Fatgong,Fatshan,Fotan,Fuktien,Fumun,Funggong,Funghoi,Fungshun,Fungtei,Gamtin,Gochau,Goming,Gonghoi,Gongshing,Goyiu,Hanghau,Hangmei,Hashan,Hengfachuen,Hengon,Heungchau,Heunggong,Heungkiu,Hingning,Hohfuktong,Hoichue,Hoifung,Hoiping,Hokong,Hokshan,Homantin,Hotin,Hoyuen,Hunghom,Hungshuikiu,Jiuling,Kamping,Kamsheung,Kamwan,Kaulongtong,Keilun,Kinon,Kinsang,Kityeung,Kongmun,Kukgong,Kwaifong,Kwaihing,Kwongchau,Kwongling,Kwongming,Kwuntong,Laichikok,Laiking,Laiwan,Lamtei,Lamtin,Leitung,Leungking,Limkong,Linchau,Linnam,Linping,Linshan,Loding,Lokcheong,Lokfu,Lokmachau,Longchuen,Longgong,Longmun,Longping,Longwa,Longwu,Lowu,Luichau,Lukfung,Lukho,Lungmun,Macheung,Maliushui,Maonshan,Mauming,Maunam,Meifoo,Mingkum,Mogong,Mongkok,Muichau,Muigong,Muiyuen,Naiwai,Namcheong,Namhoi,Namhong,Namo,Namsha,Namshan,Nganwai,Ngchuen,Ngoumun,Ngwa,Nngautaukok,Onting,Pakwun,Paotoishan,Pingshan,Pingyuen,Poklo,Polam,Pongon,Poning,Potau,Puito,Punyue,Saiwanho,Saiyingpun,Samshing,Samshui,Samtsen,Samyuenlei,Sanfung,Sanhing,Sanhui,Sanwai,Sanwui,Seiwui,Shamshuipo,Shanmei,Shantau,Shatin,Shatinwai,Shaukeiwan,Shauking,Shekkipmei,Shekmun,Shekpai,Sheungshui,Shingkui,Shiuhing,Shundak,Shunyi,Shupinwai,Simshing,Siuhei,Siuhong,Siukwan,Siulun,Suikai,Taihing,Taikoo,Taipo,Taishuihang,Taiwai,Taiwo,Taiwohau,Tinhau,Tinho,Tinking,Tinshuiwai,Tiukengleng,Toishan,Tongfong,Tonglowan,Tsakyoochung,Tsamgong,Tsangshing,Tseungkwano,Tsihing,Tsimshatsui,Tsinggong,Tsingshantsuen,Tsingwun,Tsingyi,Tsingyuen,Tsiuchau,Tsuenshekshan,Tsuenwan,Tuenmun,Tungchung,Waichap,Waichau,Waidong,Wailoi,Waishing,Waiyeung,Wanchai,Wanfau,Wanon,Wanshing,Wingon,Wongchukhang,Wongpo,Wongtaisin,Woping,Wukaisha,Yano,Yaumatei,Yauoi,Yautong,Yenfa,Yeungchun,Yeungdong,Yeunggong,Yeungsai,Yeungshan,Yimtin,Yingdak,Yiuping,Yongshing,Yongyuen,Yuenlong,Yuenshing,Yuetsau,Yuknam,Yunping,Yuyuen"},
{name: "Mongolian", i: 31, min: 5, max: 12, d: "aou", m: .3, b: "Adaatsag,Airag,Alag Erdene,Altai,Altanshiree,Altantsogts,Arbulag,Baatsagaan,Batnorov,Batshireet,Battsengel,Bayan Adarga,Bayan Agt,Bayanbulag,Bayandalai,Bayandun,Bayangovi,Bayanjargalan,Bayankhongor,Bayankhutag,Bayanlig,Bayanmonkh,Bayannuur,Bayan Ondor,Bayan Ovoo,Bayantal,Bayantsagaan,Bayantumen,Bayan Uul,Bayanzurkh,Berkh,Biger,Binder,Bogd,Bombogor,Bor Ondor,Bugat,Bulgan,Buregkhangai,Burentogtokh,Buutsagaan,Buyant,Chandmani,Chandmani Ondor,Choibalsan,Chuluunkhoroot,Chuluut,Dadal,Dalanjargalan,Dalanzadgad,Darkhan,Darvi,Dashbalbar,Dashinchilen,Delger,Delgerekh,Delgerkhaan,Delgerkhangai,Delgertsogt,Deluun,Deren,Dorgon,Duut,Erdene,Erdenebulgan,Erdeneburen,Erdenedalai,Erdenemandal,Erdenetsogt,Galshar,Galt,Galuut,Govi Ugtaal,Gurvan,Gurvanbulag,Gurvansaikhan,Gurvanzagal,Ikhkhet,Ikh Tamir,Ikh Uul,Jargalan,Jargalant,Jargaltkhaan,Jinst,Khairkhan,Khalhgol,Khaliun,Khanbogd,Khangai,Khangal,Khankh,Khankhongor,Khashaat,Khatanbulag,Khatgal,Kherlen,Khishig Ondor,Khokh,Kholonbuir,Khongor,Khotont,Khovd,Khovsgol,Khuld,Khureemaral,Khurmen,Khutag Ondor,Luus,Mandakh,Mandal Ovoo,Mankhan,Manlai,Matad,Mogod,Monkhkhairkhan,Moron,Most,Myangad,Nogoonnuur,Nomgon,Norovlin,Noyon,Ogii,Olgii,Olziit,Omnodelger,Ondorkhaan,Ondorshil,Ondor Ulaan,Orgon,Orkhon,Rashaant,Renchinlkhumbe,Sagsai,Saikhan,Saikhandulaan,Saikhan Ovoo,Sainshand,Saintsagaan,Selenge,Sergelen,Sevrei,Sharga,Sharyngol,Shine Ider,Shinejinst,Shiveegovi,Sumber,Taishir,Tarialan,Tariat,Teshig,Togrog,Tolbo,Tomorbulag,Tonkhil,Tosontsengel,Tsagaandelger,Tsagaannuur,Tsagaan Ovoo,Tsagaan Uur,Tsakhir,Tseel,Tsengel,Tsenkher,Tsenkhermandal,Tsetseg,Tsetserleg,Tsogt,Tsogt Ovoo,Tsogttsetsii,Tunel,Tuvshruulekh,Ulaanbadrakh,Ulaankhus,Ulaan Uul,Uyench,Yesonbulag,Zag,Zamyn Uud,Zereg"},
// fantasy bases by Dopu:
{name: "Human Generic", i: 32, min: 6, max: 11, d: "peolst", m: 0, b: "Grimegrove,Cliffshear,Eaglevein,Basinborn,Whalewich,Faypond,Pondshade,Earthfield,Dustwatch,Houndcall,Oakenbell,Wildwell,Direwallow,Springmire,Bayfrost,Fearwich,Ghostdale,Cursespell,Shadowvein,Freygrave,Freyshell,Tradewick,Grasswallow,Kilshell,Flatwall,Mosswind,Edgehaven,Newfalls,Flathand,Lostcairn,Grimeshore,Littleshade,Millstrand,Snowbay,Quickbell,Crystalrock,Snakewharf,Oldwall,Whitvalley,Stagport,Deadkeep,Claymond,Angelhand,Ebonhold,Shimmerrun,Honeywater,Gloomburn,Arrowburgh,Slyvein,Dawnforest,Dirtshield,Southbreak,Clayband,Oakenrun,Graypost,Deepcairn,Lagoonpass,Cavewharf,Thornhelm,Smoothwallow,Lightfront,Irongrave,Stonespell,Cavemeadow,Millbell,Shimmerwell,Eldermere,Roguehaven,Dogmeadow,Pondside,Springview,Embervault,Dryhost,Bouldermouth,Stormhand,Oakenfall,Clearguard,Lightvale,Freyshear,Flameguard,Bellcairn,Bridgeforest,Scorchwich,Mythgulch,Maplesummit,Mosshand,Iceholde,Knightlight,Dawnwater,Laststar,Westpoint,Goldbreach,Falsevale,Pinegarde,Shroudrock,Whitwharf,Autumnband,Oceanstar,Rosedale,Snowtown,Chillstrand,Saltmouth,Crystalsummit,Redband,Thorncairn,Beargarde,Pearlhaven,Lostward,Northpeak,Sandhill,Cliffgate,Sandminster,Cloudcrest,Mythshear,Dragonward,Coldholde,Knighttide,Boulderharbor,Faybarrow,Dawnpass,Pondtown,Timberside,Madfair,Crystalspire,Shademeadow,Dragonbreak,Castlecross,Dogwell,Caveport,Wildlight,Mudfront,Eldermere,Midholde,Ravenwall,Mosstide,Everborn,Lastmere,Dawncall,Autumnkeep,Oldwatch,Shimmerwood,Eldergate,Deerchill,Fallpoint,Silvergulch,Cavemire,Deerbrook,Pinepond,Ravenside,Thornyard,Scorchstall,Swiftwell,Roguereach,Cloudwood,Smoothtown,Kilhill,Ironhollow,Stillhall,Rustmore,Ragefair,Ghostward,Deadford,Smallmire,Barebreak,Westforest,Bonemouth,Evercoast,Sleekgulch,Neverfront,Lostshield,Icelight,Quickgulch,Brinepeak,Hollowstorm,Limeband,Basinmore,Steepmoor,Blackford,Stormtide,Wildyard,Wolfpass,Houndburn,Pondfalls,Pureshell,Silvercairn,Houndwallow,Dewmere,Fearpeak,Bellstall,Diredale,Crowgrove,Moongulf,Kilholde,Sungulf,Baremore,Bleakwatch,Farrun,Grimeshire,Roseborn,Heartford,Scorchpost,Cloudbay,Whitlight,Timberham,Cloudmouth,Curseminster,Basinfrost,Maplevein,Sungarde,Cloudstar,Bellport,Silkwich,Ragehall,Bellreach,Swampmaw,Snakemere,Highbourne,Goldyard,Lakemond,Shadeville,Mightmouth,Nevercrest,Pinemount,Claymouth,Rosereach,Oldreach,Brittlehelm,Heartfall,Bonegulch,Silkhollow,Crystalgulf,Mutewell,Flameside,Blackwatch,Greenwharf,Moonacre,Beachwick,Littleborough,Castlefair,Stoneguard,Nighthall,Cragbury,Swanwall,Littlehall,Mudford,Shadeforest,Mightglen,Millhand,Easthill,Amberglen,Nighthall,Cragbury,Swanwall,Littlehall,Mudford,Shadeforest,Mightglen,Millhand,Easthill,Amberglen,Smoothcliff,Lakecross,Quicklight,Eaglecall,Silentkeep,Dragonshear,Ebonfront,Oakenmeadow,Cliffshield,Stormhorn,Cavefell,Wildedenn,Earthgate,Brittlecall,Swangarde,Steamwallow,Demonfall,Sleethallow,Mossstar,Dragonhold,Smoothgrove,Sleetrun,Flamewell,Mistvault,Heartvault,Newborough,Deeppoint,Littlehold,Westshell,Caveminster,Swiftshade,Grimwood,Littlemire,Bridgefalls,Lastmere,Fayyard,Madham,Curseguard,Earthpass,Silkbrook,Winterview,Grimeborough,Dustcross,Dogcoast,Dirtstall,Oxlight,Pondstall,Sleetglen,Ghostpeak,Snowshield,Loststar,Chillwharf,Sleettide,Millgulch,Whiteshore,Sunmond,Moonwell,Grassdrift,Westmeadow,Crowvault,Everchill,Bearmire,Bronzegrasp,Oxbrook,Cursefield,Steammouth,Smoothham,Arrowdenn,Stillstrand,Mudwich"},
{name: "Elven", i: 33, min: 6, max: 12, d: "lenmsrg", m: 0, b: "Adrindest,Aethel,Afranthemar,Aggar,Aiqua,Alari,Allanar,Allanbelle,Almalian,Alora,Alyanasari,Alyelona,Alyran,Amenalenora,Ammar,Amymabelle,Ancalen,AnhAlora,Anore,Anyndell,Arasari,Aren,Ashesari,Ashletheas,Ashmebel,Asrannore,Athelle,Aymlume,Baethei,Bel-Didhel,Belanore,Borethanil,Brinorion,Caelora,Chaggaust,Chaulssad,Chaundra,ChetarIthlin,Cyhmel,Cyla,Cyonore,Cyrangroth,Doladress,Dolarith,Dolasea,Dolonde,Dorthore,Dorwine,Draethe,Dranzan,Draugaust,Dreghei,Drelhei,Dryndlu,E'ana,E'ebel,Eahil,Edhil,Edraithion,Efho,Efranluma,Efvanore,Einyallond,Elathlume,Eld-Sinnocrin,Elddrinn,Elelthyr,Elheinn,Ellanalin,Ellena,Ellheserin,Ellnlin,Ellorthond,Elralara,Elstyr,Eltaesi,Elunore,Eman,EmneLenora,Emyel,Emyranserine,Enhethyr,Ennore,Entheas,Eriargond,Erranlenor,ErrarIthinn,Esari,Esath,Eserius,Eshsalin,Eshthalas,Esseavad,Esyana,EsyseAiqua,Evraland,Faellenor,Faladhell,Famelenora,Fethalas,Filranlean,Filsaqua,Formarion,Ferdor,Gafetheas,GafSerine,Gansari,Geliene,Gondorwin,Guallu,Haeth,Hanluna,Haulssad,Helatheas,Hellerien,Heloriath,Himlarien,Himliene,Hinnead,Hlaughei,Hlinas,Hloireenil,Hluihei,Hluitar,Hlurthei,Hlynead,Iaenarion,Ifrennoris,IllaAncalen,Illanathaes,Illfanora,Imlarlon,Imyfaluna,Imyse,Imyvelian,Inferius,Inhalon,Inllune,Inlurth,innsshe,Inransera,Iralserin,Irethtalos,Irholona,Ishal,Ishlashara,Isyenshara,Ithelion,Iymerius,Iaron,Iulil,Jaal,Jamkadi,Kaalume,Kaansera,Kalthalas,Karanthanil,Karnosea,Kasethyr,Keatheas,Kelsya,KethAiqua,Kmlon,Kyathlenor,Kyhasera,Lahetheas,Lammydr,Lefdorei,Lelhamelle,Lelon,Lenora,Lilean,Lindoress,Lindeenil,Lirillaquen,Litys,Llaughei,Llurthei,Lya,Lyenalon,Lyfa,Lylharion,Lylmhil,Lynathalas,Lir,Machei,Masenoris,Mathathlona,Mathethil,Mathntheas,Meethalas,Melelume,Menyamar,Menzithl,Minthyr,Mithlonde,Mornetheas,Mytha,Mythnserine,Mythsemelle,Mythsthas,Myvanas,Naahona,Nalore,NasadIlaurth,Nasin,Nathemar,Navethas,Neadar,Neanor,Neilon,Nelalon,Nellean,Nelnetaesi,Nfanor,Nilenathyr,Nionande,Nurtaleewe,Nylm,Nytenanas,Nythanlenor,Nythfelon,Nythodorei,Nytlenor,Nidiel,Noruiben,O'anlenora,O'lalona,Obeth,Ofaenathyr,Oflhone,Ollethlune,Ollmarion,Ollmnaes,Ollsmel,Olranlune,Olyaneas,Olynahil,Omanalon,Omyselon,Onelion,Onelond,Onylanor,Orlormel,Orlynn,Ormrion,Oshana,Oshmahona,Oshvamel,Raethei,Raineas,Rauguall,Rauthe,Rauthei,Reisera,Reslenora,Rrharrvhei,Ryanasera,Rymaserin,Sahnor,Saselune,Sel-Zedraazin,Selananor,Sellerion,Selmaluma,Serin,Serine,Shaeras,Shemnas,Shemserin,Sheosari,Sileltalos,Siriande,Siriathil,Sohona,Srannor,Sshanntyr,Sshaulssin,Sshaulu,Syholume,Sylharius,Sylranbel,Symdorei,Syranbel,Szoberr,Silon,Taesi,Thalas,Thalor,Thalore,Tharenlon,Tharlarast,Thelethlune,Thelhohil,Thelnora,Themar,Thene,Thilfalean,Thilnaenor,Thvethalas,Thylathlond,Tiregul,Tirion,Tlauven,Tlindhe,Ulal,Ullallanar,Ullmatalos,Ullve,Ulmetheas,Ulrenserine,Ulssin,Umnalin,Umye,Umyheserine,Unanneas,Unarith,Undraeth,Unysarion,Vel-Shonidor,Venas,Vinargothr,Waethe,Wasrion,Wlalean,Y'maqua,Yaeluma,Yeelume,Yele,Yethrion,Ymserine,Yueghed,Yuereth,Yuerran,Yuethin,Nandeedil,Olwen,Yridhremben"},
{name: "Dark Elven", i: 34, min: 6, max: 14, d: "nrslamg", m: .2, b: "Abaethaggar,Abburth,Afranthemar,Aharasplit,Aidanat,Ald'ruhn,Ashamanu,Ashesari,Ashletheas,Baerario,Baereghel,Baethei,Bahashae,Balmora,Bel-Didhel,Borethanil,Buiyrandyn,Caellagith,Caellathala,Caergroth,Caldras,Chaggar,Chaggaust,Channtar,Charrvhel'raugaust,Chaulssin,Chaundra,ChedNasad,ChetarIthlin,ChethRrhinn,Chymaer,Clarkarond,Cloibbra,Commoragh,Cyrangroth,Cilben,D'eldarc,Daedhrog,Dalkyn,Do'Urden,Doladress,Dolarith,Dolonde,Draethe,Dranzan,Dranzithl,Draugaust,Dreghei,Drelhei,Dryndlu,Dusklyngh,DyonG'ennivalz,Edraithion,Eld-Sinnocrin,Ellorthond,Enhethyr,Entheas,ErrarIthinn,Eryndlyn,Faladhell,Faneadar,Fethalas,Filranlean,Formarion,Ferdor,Gafetheas,Ghrond,Gilranel,Glamordis,Gnaarmok,Gnisis,Golothaer,Gondorwin,Guallidurth,Guallu,Gulshin,Haeth,Haggraef,Harganeth,Harkaldra,Haulssad,Haundrauth,Heloriath,Hlammachar,Hlaughei,Hloireenil,Hluitar,Inferius,innsshe,Ithilaughym,Iz'aiogith,Jaal,Jhachalkhyn,Kaerabrae,Karanthanil,Karondkar,Karsoluthiyl,Kellyth,Khuul,Lahetheas,Lidurth,Lindeenil,Lirillaquen,LithMy'athar,LlurthDreier,Lolth,Lothuial,Luihaulen'tar,Maeralyn,Maerimydra,Mathathlona,Mathethil,Mellodona,Menagith,Menegwen,Menerrendil,Menzithl,Menzoberranzan,Mila-Nipal,Mithryn,Molagmar,Mundor,Myvanas,Naggarond,NasadIlaurth,Nauthor,Navethas,Neadar,Nurtaleewe,Nidiel,Noruiben,O'lalona,Obeth,Ofaenathyr,Orlormel,Orlytlar,Pelagiad,Raethei,Raugaust,Rauguall,Rilauven,Rrharrvhei,Sadrith,Sel-Zedraazin,Seydaneen,Shaz'rir,Skaal,Sschindylryn,Shamath,Shamenz,Shanntur,Sshanntynlan,Sshanntyr,Shaulssin,SzithMorcane,Szithlin,Szobaeth,Sirdhemben,T'lindhet,Tebh'zhor,Telmere,Telnarquel,Tharlarast,Thylathlond,Tlaughe,Trizex,Tyrybblyn,Ugauth,Ughym,Ullmatalos,Ulmetheas,Ulrenserine,Uluitur,Undraeth,Undraurth,Undrek'Thoz,Ungethal,UstNatha,V'elddrinnsshar,Vaajha,Vel-Shonidor,Velddra,Velothi,Venead,Vhalth'vha,Vinargothr,Vojha,Waethe,Waethei,Xaalkis,Yakaridan,Yeelume,Yuethin,Yuethindrynn,Zirnakaynin,Nandeedil,olwen,Uhaelben,Uthaessien,Yridhremben"},
{name: "Dwarven", i: 35, min: 4, max: 11, d: "dk", m: 0, b: "Addundad,Ahagzad,Ahazil,Akil,Akzizad,Anumush,Araddush,Arar,Arbhur,Badushund,Baragzig,Baragzund,Barakinb,Barakzig,Barakzinb,Barakzir,Baramunz,Barazinb,Barazir,Bilgabar,Bilgatharb,Bilgathaz,Bilgila,Bilnaragz,Bilnulbar,Bilnulbun,Bizaddum,Bizaddush,Bizanarg,Bizaram,Bizinbiz,Biziram,Bunaram,Bundinar,Bundushol,Bundushund,Bundushur,Buzaram,Buzundab,Buzundush,Gabaragz,Gabaram,Gabilgab,Gabilgath,Gabizir,Gabunal,Gabunul,Gabuzan,Gatharam,Gatharbhur,Gathizdum,Gathuragz,Gathuraz,Gila,Giledzir,Gilukkhath,Gilukkhel,Gunala,Gunargath,Gunargil,Gundumunz,Gundusharb,Gundushizd,Kharbharbiln,Kharbhatharb,Kharbhela,Kharbilgab,Kharbuzadd,Khatharbar,Khathizdin,Khathundush,Khazanar,Khazinbund,Khaziragz,Khaziraz,Khizdabun,Khizdusharbh,Khizdushath,Khizdushel,Khizdushur,Kholedzar,Khundabiln,Khundabuz,Khundinarg,Khundushel,Khuragzig,Khuramunz,Kibarak,Kibilnal,Kibizar,Kibunarg,Kibundin,Kibuzan,Kinbadab,Kinbaragz,Kinbarakz,Kinbaram,Kinbizah,Kinbuzar,Nala,Naledzar,Naledzig,Naledzinb,Naragzah,Naragzar,Naragzig,Narakzah,Narakzar,Naramunz,Narazar,Nargabad,Nargabar,Nargatharb,Nargila,Nargundum,Nargundush,Nargunul,Narukthar,Narukthel,Nula,Nulbadush,Nulbaram,Nulbilnarg,Nulbunal,Nulbundab,Nulbundin,Nulbundum,Nulbuzah,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhund,Nulukkhur,Sharakinb,Sharakzar,Sharamunz,Sharbarukth,Shatharbhizd,Shatharbiz,Shathazah,Shathizdush,Shathola,Shaziragz,Shizdinar,Shizdushund,Sholukkharb,Shundinulb,Shundushund,Shurakzund,Shuramunz,Tumunzadd,Tumunzan,Tumunzar,Tumunzinb,Tumunzir,Ukthad,Ulbirad,Ulbirar,Ulunzar,Ulur,Umunzad,Undalar,Undukkhil,Undun,Undur,Unduzur,Unzar,Unzathun,Usharar,Zaddinarg,Zaddushur,Zaharbad,Zaharbhizd,Zarakib,Zarakzar,Zaramunz,Zarukthel,Zinbarukth,Zirakinb,Zirakzir,Ziramunz,Ziruktharbh,Zirukthur,Zundumunz"},
{name: "Goblin", i: 36, min: 4, max: 9, d: "eag", m: 0, b: "Crild,Cielb,Srurd,Fruict,Xurx,Crekork,Strytzakt,Ialsirt,Gnoklig,Kleardeek,Gobbledak,Thelt,Swaxi,Ulm,Shaxi,Thult,Jasheafta,Kleabtong,Bhiagielt,Kuipuinx,Hiszils,Nilbog,Gneabs,Stiolx,Esz,Honk,Veekz,Vohniots,Bratliaq,Slehzit,Diervaq,Zriokots,Buyagh,Treaq,Phax,Ilm,Blus,Srefs,Biokvish,Gigganqi,Watvielx,Katmelt,Slofboif,gobbok,Klilm,Blix,Qosx,Fygsee,Moft,Asinx,Joimtoilm,Styrzangai,Prolkeh,Stioskurt,Mogg,Cel,Far,Rekx,Chalk,Paas,Brybsil,Utiarm,Eebligz,Iahzaarm,Stuikvact,Gobbrin,Ish,Suirx,Utha,Taxai,Onq,Stiaggaltia,Dobruing,Breshass,Cosvil,Traglila,Felhob,Hobgar,Preang,Sios,Wruilt,Chox,Pyreazzi,Glamzofs,Froihiofz,Givzieqee,Vreagaald,Bugbig,Kluirm,Ulb,Driord,Stroir,Croibieq,Bridvelb,Wrogdilk,Slukex,Ozbiard,Gagablin,Heszai,Kass,Chiafzia,Thresxea,Een,Oimzoishai,Enissee,Glernaahx,Qeerags,Phigheldai,Ziggek"},
{name: "Orc", i: 37, min: 4, max: 8, d: "gzrcu", m: 0, b: "ModhOdod,BodRugniz,Rildral,Zalbrez,Bebugh,Grurro,Ibruzzed,Goccogmurd,CheganKhed,BedgezGraz,IkhUgnan,NoGolkon,Dhezza,Chuccuz,Dribor,Khezdrugh,Uzdriboz,Nolgazgredh,KrogvurOz,ZrucraBi,ErLigvug,OkhUggekh,Vrobrun,Raggird,Adgoz,Chugga,Ghagrocroz,Khuldrerradh,IrmekhBhor,KuzgrurdDedh,ZunBergrord,AdhKhorkol,Alzokh,Mubror,Bozdra,Brugroz,Nuzecro,Qidzodkakh,GharedKrin,OrkudhBhur,EkhKrerdrugh,KrarZurmurd,Nuccag,Rezegh,Lorgran,Grergran,Nadguggez,Mocculdrer,BrorkrilZrog,RurguzVig,CharRodkeg,UghBhelgag,Zulbriz,Rodrekh,Erbragh,Bhicrur,Arkugzo,Arrordri,MiccolBurd,OddighKrodh,UghVruron,VrughNardrer,Dhoddud,Murmad,Chuzar,Vrazin,Ridgozedh,Lazzogno,MughakhChil,VrolburNur,KrighBhurdin,GhadhDrurzan,Adran,Chazgro,Krorgrug,Grodzakh,Ugrudraz,Iggulzaz,KudrukhLi,QuccuBan,GrighKaggaz,ArdGrughokh,Zolbred,Drozgrir,Agkadh,Zuggedh,Lulkore,Dhulbazzol,DhazonNer,ZrazzuzVaz,BrurKorre,EkhMezred,Vaddog,Drirdradh,Qashnagh,Arad,Zadarord,Khorbriccord,NelzorZroz,DruccoRad,DhodhBrerdrodh,BhakhZradgukh,Qirrer,Uzord,Bulbredh,Khuzdraz,Churgrorgadh,Legvicrodh,GazdrakhVrard,VagordKhod,GidhUcceg,BhogKirgol,Brogved,Aga,Kudzal,Brolzug,Ughudadh,Noshnogradh,ZubodUr,ZrulbukhDekh,ReVurkog,RoghChirzaz,Kharkiz,Bhogug,Bozziz,Vuccidh,Ruddirgrad,Zordrordud,GrirkrunQur,IbulBrad,AdAdzurd,GaghDruzred,Acran,Morbraz,Drurgin,Chogidh,Nogvolkar,Uzaggor,KazzuzZrar,ArrulChukh,DiChudun,GhoUgnud,Uzron,Uzdroz,Gholgard,Zragmukh,Qiddolzog,Reradgri,QiccadChal,NubudId,ZrardKrodog,KrudhKhogzokh,Vizdrun,Orrad,Darmon,Ulkin,Zigmorbredh,Bizzadurd,MuccugGhuz,MabraghBhard,DurKhaddol,BheghGegnod,Qazzudh,Drobagh,Zorrudh,Dodkakh,Gribrabrokh,Quggidkad,DududhAkh,DrizdedhAd,GhordBhozdrokh,ZadEzzedh,Larud,Ashnedh,Gridkog,Qirzodh,Bhirgoshbel,Ghirmarokh,ArizDru,AgzilGhal,DrodhAshnugh,UghErrod,Lugekh,Buccel,Rarurd,Verrugh,Qommorbrord,Bagzildre,NazadLudh,IbaghChol,GrazKhulgag,QigKrorkodh,Rozzez,Koggodh,Ruzgrin,Zrigud,Zragrizgrakh,Irdrelzug,VrurzarMol,KezulBruz,GurGhogkagh,KigRadkodh,Ulgor,Kroddadh,Eldrird,Bozgrun,Digzagkigh,Azrurdrekh,KhuzdordDugh,DhurkighGrer,MeGheggor,KoGerkradh,Bashnud,Nirdrukh,Adog,Egmod,Vruzzegvukh,Nagrubagh,DugkegVuz,MorkirZrudh,NudhKuldra,DhodhGhigin,Graldrodh,Rero,Merkraz,Ummo,Largraragh,Brordeggeg,UldrukhBhudh,DregvekhOg,GughZozgrod,GhidZrogiz,Khebun,Ordol,Ghadag,Dredagh,Bhiccozdur,Chizeril,KarkorZrid,EmmanMaz,LiBogzel,EkhBeccon,Dashnukh,Kacruz,Krummel,Dirdrurd,Khalbammedh,Dhizdrermodh,GharuZrug,BhurkrukhLen,ZuZredzokh,BralLazogh,Velgrudh,Dorgri,Irbraz,Udral,Bigkurel,Zarralkod,DhoggunBhogh,AdgrilGha,DrukhQodgoz,KaNube,Vrurgu,Mazgar,Lalga,Bolkan,Kudgroccukh,Zraldrozzuz,VorordUz,ZacradLe,BrukhZrabrul,GagDrugmag,Kraghird,Bhummagh,Brazadh,Kalbrugh,Brogzozir,Mugmodror,RezgrughErd,UmmughEkh,GuNuccul,VunGaghukh,Ghizgil,Arbran,Bulgragh,Negvidh,Girodgrurd,Ghedgrolbrol,DrogvukhDrodh,DhalgronMog,MulDhazzug,ChazCharard,Drurkuz,Niddeg,Bagguz,Ogkal,Rordrushnokh,Gorkozzil,KorkrirGrar,RigaghZrad"},
{name: "Giant", i: 38, min: 5, max: 10, d: "kdtng", m: 0, b: "Kostand,Throtrek,Solfod,Shurakzund,Heimfara,Anumush,Dulkun,Sigbeorn,Velhera,Glumvat,Khundinarg,Shathizdush,Baramunz,Nargunul,Magald,Noluch,Yotane,Tumunzar,Giledzir,Nurkel,Khizdabun,Yudgor,Hartreo,Galfald,Vigkan,Kibarak,Girkun,Gomruch,Guddud,Darnaric,Botharic,Gunargath,Oldstin,Rizen,Marbold,Nargundush,Hargarth,Kengord,Maerdis,Brerstin,Sigbi,Zigez,Umunzad,Nelkun,Yili,Usharar,Ranhera,Mistoch,Nuledzah,Nulbilnarg,Nulukkhur,Tulkug,Kigine,Marbrand,Gagkake,Khathizdin,Geru,Nagu,Grimor,Kaltoch,Koril,Druguk,Khatharbar,Debuch,Eraddam,Neliz,Brozu,Morluch,Enuz,Gatal,Beratira,Gurkale,Gluthmark,Iora,Tozage,Agane,Kegkez,Nuledzig,Bahourg,Jornangar,Kilfond,Dankuc,Rurki,Eldond,Barakzig,Olane,Gostuz,Grimtira,Brildung,Nulbaram,Nargabar,Narazar,Natan,oci,Khaziragz,Gabuzan,Orga,Addundad,Yulkake,Nulukkhaz,Bundushund,Guril,Barakinb,Sadgach,Vylwed,Vozig,Hildlaug,Chergun,Dagdhor,Kibizar,Shundushund,Mornkin,Jaldhor,inez,Lingarth,Churtec,Naragzah,Gabizir,Zugke,Ranava,Minu,Barazinb,Fynwyn,Talkale,Widhyrde,Sidga,Velfirth,Varkud,Shathola,Duhal,Srokvan,Guruge,Lindira,Rannerg,Kilkan,Gudgiz,Baragzund,Aerora,Inginy,Kharbharbiln,Theoddan,Rirkan,Undukkhil,Borgbert,Dina,Gortho,Kinbuzar,Kuzake,Drard,Gorkege,Nargatharb,Diru,Shatharbiz,Sgandrol,Sharakzar,Barakzinb,Dinez,Jarwar,Khizdushel,Wylaeya,Khazanar,Beornelde,Arangrim,Sholukkharb,Stighere,Gulwo,Irkin,Jornmoth,Gundusharb,Gabaram,Shizdinar,Memron,Guzi,Naramunz,Morntaric,Somrud,Norginny,Bremrol,Rurkoc,Zugkan,Vorkige,Kinbadab,Gila,Sulduch,Natil,Idgurth,Gabaragz,Tolkeg,Eradhelm,Dugfast,Froththorn,Galgrim,Theodgrim,Valdhere,Gazin,Tigkiz,Burthug,Chazruc,Kakkek,Toren"},
{name: "Draconic", i: 39, min: 6, max: 14, d: "aliuszrox", m: 0, b: "Aaronarra,Adalon,Adamarondor,Aeglyl,Aerosclughpalar,Aghazstamn,Aglaraerose,Agoshyrvor,Alduin,Alhazmabad,Altagos,Ammaratha,Amrennathed,Anaglathos,Andrathanach,Araemra,Araugauthos,Arauthator,Arharzel,Arngalor,Arveiaturace,Athauglas,Augaurath,Auntyrlothtor,Azarvilandral,Azhaq,Balagos,Baratathlaer,Bleucorundum,BrazzPolis,Canthraxis,Capnolithyl,Charvekkanathor,Chellewis,Chelnadatilar,Cirrothamalan,Claugiyliamatar,Cragnortherma,Dargentum,Dendeirmerdammarar,Dheubpurcwenpyl,Domborcojh,Draconobalen,Dragansalor,Dupretiskava,Durnehviir,Eacoathildarandus,Eldrisithain,Enixtryx,Eormennoth,Esmerandanna,Evenaelorathos,Faenphaele,Felgolos,Felrivenser,Firkraag,Fll'Yissetat,Furlinastis,Galadaeros,Galglentor,Garnetallisar,Garthammus,Gaulauntyr,Ghaulantatra,Glouroth,Greshrukk,Guyanothaz,Haerinvureem,Haklashara,Halagaster,Halaglathgar,Havarlan,Heltipyre,Hethcypressarvil,Hoondarrh,Icehauptannarthanyx,Iiurrendeem,Ileuthra,Iltharagh,Ingeloakastimizilian,Irdrithkryn,Ishenalyr,Iymrith,Jaerlethket,Jalanvaloss,Jhannexydofalamarne,Jharakkan,Kasidikal,Kastrandrethilian,Khavalanoth,Khuralosothantar,Kisonraathiisar,Kissethkashaan,Kistarianth,Klauth,Klithalrundrar,Krashos,Kreston,Kriionfanthicus,Krosulhah,Krustalanos,Kruziikrel,Kuldrak,Lareth,Latovenomer,Lhammaruntosz,Llimark,Ma'fel'no'sei'kedeh'naar,MaelestorRex,Magarovallanthanz,Mahatnartorian,Mahrlee,Malaeragoth,Malagarthaul,Malazan,Maldraedior,Maldrithor,MalekSalerno,Maughrysear,Mejas,Meliordianix,Merah,Mikkaalgensis,Mirmulnir,Mistinarperadnacles,Miteach,Mithbarazak,Morueme,Moruharzel,Naaslaarum,Nahagliiv,Nalavarauthatoryl,Naxorlytaalsxar,Nevalarich,Nolalothcaragascint,Nurvureem,Nymmurh,Odahviing,Olothontor,Ormalagos,Otaaryliakkarnos,Paarthurnax,Pelath,Pelendralaar,Praelorisstan,Praxasalandos,Protanther,Qiminstiir,Quelindritar,Ralionate,Rathalylaug,Rathguul,Rauglothgor,Raumorthadar,Relonikiv,Ringreemeralxoth,Roraurim,Ruuthundrarar,Rylatar'ralah'tyma,Rynnarvyx,Sablaxaahl,Sahloknir,Sahrotaar,Samdralyrion,Saryndalaghlothtor,Sawaka,Shalamalauth,Shammagar,Sharndrel,Shianax,Skarlthoon,Skurge,Smergadas,Ssalangan,Sssurist,Sussethilasis,Sylvallitham,Tamarand,Tantlevgithus,Taraunramorlamurla,Tarlacoal,Tenaarlaktor,Thalagyrt,Tharas'kalagram,Thauglorimorgorus,Thoklastees,Thyka,Tsenshivah,Ueurwen,Uinnessivar,Urnalithorgathla,Velcuthimmorhar,Velora,Vendrathdammarar,Venomindhar,Viinturuth,Voaraghamanthar,Voslaarum,Vr'tark,Vrondahorevos,Vuljotnaak,Vulthuryol,Wastirek,Worlathaugh,Xargithorvar,Xavarathimius,Yemere,Ylithargathril,Ylveraasahlisar,Za-Jikku,Zarlandris,Zellenesterex,Zilanthar,Zormapalearath,Zundaerazylym,Zz'Pzora"},
{name: "Arachnid", i: 40, min: 4, max: 10, d: "erlsk", m: 0, b: "Aaqok'ser,Acah,Aiced,Aisi,Aizachis,Allinqel,As'taq,Ashrash,Caaqtos,Caq'zux,Ceek'sax,Ceezuq,Cek'siereel,Cen'qi,Ceqru,Ceqzocer,Cezeed,Chachocaq,Charis,Chashar,Chashilieth,Checib,Chen'qal,Chernul,Cherzoq,Chezi,Chiazu,Chikoqal,Chishros,Chixhi,Chizhi,Chizoser,Chollash,Choq'sha,Chouk'rix,Cinchichail,Collul,Ecush'taid,Eenqachal,Ekiqe,El'zos,El'zur,Ellu,Eq'tur,Eqa,Eqas,Er'uria,Erikas,Ertu,Es'tase,Esrub,Evirrot,Exha,Haqsho,Heekath,Hiavheesh,Hitha,Hok'thi,Hossa,Iacid,Iciever,Ik'si,Illuq,Iri,Isicer,Isnir,Ivrid,Kaalzux,Keezut,Kheellavas,Kheizoh,Khellinqesh,Khiachod,Khika,Khinchi,Khirzur,Khivila,Khonrud,Khontid,Khosi,Khrakku,Khraqshis,Khrerrith,Khrethish'ti,Khriashus,Khrika,Khrirni,Khrocoqshesh,Klashirel,Klassa,Kleil'sha,Kliakis,Klishuth,Klith'osha,Krarnit,Kras'tex,Kreelzi,Krivas,Krotieqas,Laco,Lairta,Lais'tid,Laizuh,Lasnoth,Lekkol,Len'qeer,Leqanches,Lezad,Lhezsi,Lhilir,Lhivhath,Lhok'thu,Lialliesed,Liaraq,Liarisriq,Liceva,Lichorro,Lilla,Livorzish,Lokieqib,Nakar,Nakur,Naros,Natha,Necuk'saih,Neerhaca,Neet'er,Neezoh,Nenchiled,Nerhalneth,Nir'ih,Nizus,Noreeqo,Novalsher,On'qix,Qailloncho,Qak'sovo,Qalitho,Qartori,Qas'tor,Qasol,Qavrud,Qavud,Qazar,Qazieveq,Qazru,Qeik'thoth,Qekno,Qeqravee,Qes'tor,Qhaaviq,Qhaik'sal,Qhak'sish,Qhazsakais,Qhechorte,Qheliva,Qhenchaqes,Qherazal,Qhesoh,Qhiallud,Qhon'qos,Qhoshielleed,Qish'tur,Qisih,Qollal,Qorhoci,Qouxet,Qranchiq,Racith,Rak'zes,Ranchis,Rarhie,Rarzi,Rarzisiaq,Ras'tih,Ravosho,Recad,Rekid,Relshacash,Reqishee,Rernee,Rertachis,Rezhokketh,Reziel,Rhacish,Rhail'shel,Rhairhizse,Rhakivex,Rhaqeer,Rhartix,Rheciezsei,Rheevid,Rhel'shir,Rhetovraix,Rhevhie,Rhialzub,Rhiavekot,Rhikkos,Rhiqese,Rhiqi,Rhiqracar,Rhisned,Rhokno,Rhousnateb,Rhouvaqid,Riakeesnex,Rik'sid,Rintachal,Rir'ul,Rorrucis,Rosharhir,Rourk'u,Rouzakri,Sailiqei,Sanchiqed,Sanqad,Saqshu,Sat'ier,Sazi,Seiqas,Shieth'i,Shiqsheh,Shizha,Shrachuvo,Shranqo,Shravhos,Shravuth,Shreerhod,Shrethuh,Shriantieth,Shronqash,Shrovarhir,Shrozih,Siacaqoh,Siezosh,Silrul,Siq'sha,Sirro,Sornosi,Srachussi,Sreqrud,Srirnukaaq,Szaca,Szacih,Szaqova,Szasu,Szazhilos,Szeerrud,Szeezsad,Szeknur,Szesir,Szet'as,Szetirrar,Szezhirros,Szilshith,Szon'qol,Szornuq,Xaax'uq,Xeekke,Xosax,Yaconchi,Yacozses,Yazrer,Yeek'su,Yeeq'zox,Yeqil,Yeqroq,Yeveed,Yevied,Yicaveeh,Yirresh,Yisie,Yithik'thaih,Yorhaqshes,Zacheek'sa,Zakkasa,Zaqi,Zelraq,Zeqo,Zhaivoq,Zharuncho,Zhath'arhish,Zhavirrit,Zhazilraq,Zhazot,Zhazsachiel,Zhek'tha,Zhequ,Zhias'ted,Zhicat,Zhicur,Zhiese,Zhirhacil,Zhizri,Zhochizses,Zhorkir,Ziarih,Zirnib,Zis'teq,Zivezeh"},
{name: "Serpents", i: 41, min: 5, max: 11, d: "slrk", m: 0, b: "Aj'ha,Aj'i,Aj'tiss,Ajakess,Aksas,Aksiss,Al'en,An'jeshe,Apjige,Arkkess,Athaz,Atus,Azras,Caji,Cakrasar,Cal'arrun,Capji,Cathras,Cej'han,Ces,Cez'jenta,Cij'te,Cinash,Cizran,Coth'jus,Cothrash,Culzanek,Cunaless,Ej'tesh,Elzazash,Ergek,Eshjuk,Ethris,Gan'jas,Gapja,Gar'thituph,Gopjeguss,Gor'thesh,Gragishaph,Grar'theness,Grath'ji,Gressinas,Grolzesh,Grorjar,Grozrash,Guj'ika,Harji,Hej'hez,Herkush,Horgarrez,Illuph,Ipjar,Ithashin,Kaj'ess,Kar'kash,Kepjusha,Ki'kintus,Kissere,Koph,Kopjess,Kra'kasher,Krak,Krapjez,Krashjuless,Kraz'ji,Krirrigis,Krussin,Ma'lush,Mage,Maj'tak,Mal'a,Mapja,Mar'kash,Mar'kis,Marjin,Mas,Mathan,Men'jas,Meth'jaresh,Mij'hegak,Min'jash,Mith'jas,Monassu,Moss,Naj'hass,Najugash,Nak,Napjiph,Nar'ka,Nar'thuss,Narrusha,Nash,Nashjekez,Nataph,Nij'ass,Nij'tessiph,Nishjiss,Norkkuss,Nus,Olluruss,Or'thi,Or'thuss,Paj'a,Parkka,Pas,Pathujen,Paz'jaz,Pepjerras,Pirkkanar,Pituk,Porjunek,Pu'ke,Ragen,Ran'jess,Rargush,Razjuph,Rilzan,Riss,Rithruz,Rorgiss,Rossez,Rraj'asesh,Rraj'tass,Rrar'kess,Rrar'thuph,Rras,Rrazresh,Rrej'hish,Rrigelash,Rris,Rris,Rroksurrush,Rukrussush,Rurri,Russa,Ruth'jes,Sa'kitesh,Sar'thass,Sarjas,Sazjuzush,Ser'thez,Sezrass,Shajas,Shas,Shashja,Shass,Shetesh,Shijek,Shun'jaler,Shurjarri,Skaler,Skalla,Skallentas,Skaph,Skar'kerriz,Skath'jeruk,Sker'kalas,Skor,Skoz'ji,Sku'lu,Skuph,Skur'thur,Slalli,Slalt'har,Slelziress,Slil'ar,Sloz'jisa,Sojesh,Solle,Sorge,Sral'e,Sran'ji,Srapjess,Srar'thazur,Srash,Srath'jess,Srathrarre,Srerkkash,Srus,Sruss'tugeph,Sun,Suss'tir,Uzrash,Vargush,Vek,Vess'tu,Viph,Vult'ha,Vupjer,Vushjesash,Xagez,Xassa,Xulzessu,Zaj'tiss,Zan'jer,Zarriss,Zassegus,Zirres,Zsor,Zurjass"}
];
};
return {
getBase,
getCulture,
getCultureShort,
getBaseShort,
getState,
updateChain,
clearChains,
getNameBases,
getMapName,
calculateChain
};
})();

View file

@ -0,0 +1,96 @@
import {TIME} from "/src/config/logging";
import {clipPoly} from "/src/utils/lineUtils";
import {rn} from "/src/utils/numberUtils";
import {P} from "/src/utils/probabilityUtils";
import {round} from "/src/utils/stringUtils";
window.OceanLayers = (function () {
let cells, vertices, pointsN, used;
const OceanLayers = function OceanLayers() {
const outline = oceanLayers.attr("layers");
if (outline === "none") return;
TIME && console.time("drawOceanLayers");
lineGen.curve(d3.curveBasisClosed);
(cells = grid.cells), (pointsN = grid.cells.i.length), (vertices = grid.vertices);
const limits = outline === "random" ? randomizeOutline() : outline.split(",").map(s => +s);
const chains = [];
const opacity = rn(0.4 / limits.length, 2);
used = new Uint8Array(pointsN); // to detect already passed cells
for (const i of cells.i) {
const t = cells.t[i];
if (t > 0) continue;
if (used[i] || !limits.includes(t)) continue;
const start = findStart(i, t);
if (!start) continue;
used[i] = 1;
const chain = connectVertices(start, t); // vertices chain to form a path
if (chain.length < 4) continue;
const relax = 1 + t * -2; // select only n-th point
const relaxed = chain.filter((v, i) => !(i % relax) || vertices.c[v].some(c => c >= pointsN));
if (relaxed.length < 4) continue;
const points = clipPoly(
relaxed.map(v => vertices.p[v]),
1
);
chains.push([t, points]);
}
for (const t of limits) {
const layer = chains.filter(c => c[0] === t);
let path = layer.map(c => round(lineGen(c[1]))).join("");
if (path) oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").style("opacity", opacity);
}
// find eligible cell vertex to start path detection
function findStart(i, t) {
if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= pointsN)); // map border cell
return cells.v[i][cells.c[i].findIndex(c => cells.t[c] < t || !cells.t[c])];
}
TIME && console.timeEnd("drawOceanLayers");
};
function randomizeOutline() {
const limits = [];
let odd = 0.2;
for (let l = -9; l < 0; l++) {
if (P(odd)) {
odd = 0.2;
limits.push(l);
} else {
odd *= 2;
}
}
return limits;
}
// connect vertices to chain
function connectVertices(start, t) {
const chain = []; // vertices chain to form a path
for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.t[c] === t).forEach(c => (used[c] = 1));
const v = vertices.v[current]; // neighboring vertices
const c0 = !cells.t[c[0]] || cells.t[c[0]] === t - 1;
const c1 = !cells.t[c[1]] || cells.t[c[1]] === t - 1;
const c2 = !cells.t[c[2]] || cells.t[c[2]] === t - 1;
if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {
ERROR && console.error("Next vertex is not found");
break;
}
}
chain.push(chain[0]); // push first vertex as the last one
return chain;
}
return OceanLayers;
})();

192
src/modules/relief-icons.js Normal file
View file

@ -0,0 +1,192 @@
import {getPackPolygon} from "/src/utils/graphUtils";
import {rn, minmax} from "/src/utils/numberUtils";
import {rand} from "/src/utils/probabilityUtils";
window.ReliefIcons = (function () {
const ReliefIcons = function () {
TIME && console.time("drawRelief");
terrain.selectAll("*").remove();
const cells = pack.cells;
const density = terrain.attr("density") || 0.4;
const size = 2 * (terrain.attr("size") || 1);
const mod = 0.2 * size; // size modifier
const relief = [];
for (const i of cells.i) {
const height = cells.h[i];
if (height < 20) continue; // no icons on water
if (cells.r[i]) continue; // no icons on rivers
const biome = cells.biome[i];
if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome
const polygon = getPackPolygon(i);
const [minX, maxX] = d3.extent(polygon, p => p[0]);
const [minY, maxY] = d3.extent(polygon, p => p[1]);
if (height < 50) placeBiomeIcons(i, biome);
else placeReliefIcons(i);
function placeBiomeIcons() {
const iconsDensity = biomesData.iconsDensity[biome] / 100;
const radius = 2 / iconsDensity / density;
if (Math.random() > iconsDensity * 10) return;
for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
let h = (4 + Math.random()) * size;
const icon = getBiomeIcon(i, biomesData.icons[biome]);
if (icon === "#relief-grass-1") h *= 1.2;
relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)});
}
}
function placeReliefIcons(i) {
const radius = 2 / density;
const [icon, h] = getReliefIcon(i, height);
for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)});
}
}
function getReliefIcon(i, h) {
const temp = grid.cells.temp[pack.cells.g[i]];
const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill";
const size = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6);
return [getIcon(type), size];
}
}
// sort relief icons by y+size
relief.sort((a, b) => a.y + a.s - (b.y + b.s));
let reliefHTML = "";
for (const r of relief) {
reliefHTML += `<use href="${r.i}" x="${r.x}" y="${r.y}" width="${r.s}" height="${r.s}"/>`;
}
terrain.html(reliefHTML);
TIME && console.timeEnd("drawRelief");
};
function getBiomeIcon(i, b) {
let type = b[Math.floor(Math.random() * b.length)];
const temp = grid.cells.temp[pack.cells.g[i]];
if (type === "conifer" && temp < 0) type = "coniferSnow";
return getIcon(type);
}
function getVariant(type) {
switch (type) {
case "mount":
return rand(2, 7);
case "mountSnow":
return rand(1, 6);
case "hill":
return rand(2, 5);
case "conifer":
return 2;
case "coniferSnow":
return 1;
case "swamp":
return rand(2, 3);
case "cactus":
return rand(1, 3);
case "deadTree":
return rand(1, 2);
default:
return 2;
}
}
function getOldIcon(type) {
switch (type) {
case "mountSnow":
return "mount";
case "vulcan":
return "mount";
case "coniferSnow":
return "conifer";
case "cactus":
return "dune";
case "deadTree":
return "dune";
default:
return type;
}
}
function getIcon(type) {
const set = terrain.attr("set") || "simple";
if (set === "simple") return "#relief-" + getOldIcon(type) + "-1";
if (set === "colored") return "#relief-" + type + "-" + getVariant(type);
if (set === "gray") return "#relief-" + type + "-" + getVariant(type) + "-bw";
return "#relief-" + getOldIcon(type) + "-1"; // simple
}
// mbostock's poissonDiscSampler
function* poissonDiscSampler(x0, y0, x1, y1, r, k = 3) {
if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error();
const width = x1 - x0;
const height = y1 - y0;
const r2 = r * r;
const r2_3 = 3 * r2;
const cellSize = r * Math.SQRT1_2;
const gridWidth = Math.ceil(width / cellSize);
const gridHeight = Math.ceil(height / cellSize);
const grid = new Array(gridWidth * gridHeight);
const queue = [];
function far(x, y) {
const i = (x / cellSize) | 0;
const j = (y / cellSize) | 0;
const i0 = Math.max(i - 2, 0);
const j0 = Math.max(j - 2, 0);
const i1 = Math.min(i + 3, gridWidth);
const j1 = Math.min(j + 3, gridHeight);
for (let j = j0; j < j1; ++j) {
const o = j * gridWidth;
for (let i = i0; i < i1; ++i) {
const s = grid[o + i];
if (s) {
const dx = s[0] - x;
const dy = s[1] - y;
if (dx * dx + dy * dy < r2) return false;
}
}
}
return true;
}
function sample(x, y) {
queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = [x, y]));
return [x + x0, y + y0];
}
yield sample(width / 2, height / 2);
pick: while (queue.length) {
const i = (Math.random() * queue.length) | 0;
const parent = queue[i];
for (let j = 0; j < k; ++j) {
const a = 2 * Math.PI * Math.random();
const r = Math.sqrt(Math.random() * r2_3 + r2);
const x = parent[0] + r * Math.cos(a);
const y = parent[1] + r * Math.sin(a);
if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) {
yield sample(x, y);
continue pick;
}
}
const r = queue.pop();
if (i < queue.length) queue[i] = r;
}
}
return ReliefIcons;
})();

View file

@ -0,0 +1,774 @@
import {TIME} from "/src/config/logging";
import {findAll} from "/src/utils/graphUtils";
import {unique} from "/src/utils/arrayUtils";
import {getRandomColor, getMixedColor} from "/src/utils/colorUtils";
import {rn} from "/src/utils/numberUtils";
import {rand, P, ra, rw, biased} from "/src/utils/probabilityUtils";
import {trimVowels, getAdjective, abbreviate} from "/src/utils/languageUtils";
window.Religions = (function () {
// name generation approach and relative chance to be selected
const approach = {
Number: 1,
Being: 3,
Adjective: 5,
"Color + Animal": 5,
"Adjective + Animal": 5,
"Adjective + Being": 5,
"Adjective + Genitive": 1,
"Color + Being": 3,
"Color + Genitive": 3,
"Being + of + Genitive": 2,
"Being + of the + Genitive": 1,
"Animal + of + Genitive": 1,
"Adjective + Being + of + Genitive": 2,
"Adjective + Animal + of + Genitive": 2
};
// turn weighted array into simple array
const approaches = [];
for (const a in approach) {
for (let j = 0; j < approach[a]; j++) {
approaches.push(a);
}
}
const base = {
number: ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"],
being: [
"Ancestor",
"Ancient",
"Brother",
"Chief",
"Council",
"Creator",
"Deity",
"Elder",
"Father",
"Forebear",
"Forefather",
"Giver",
"God",
"Goddess",
"Guardian",
"Lady",
"Lord",
"Maker",
"Master",
"Mother",
"Numen",
"Overlord",
"Reaper",
"Ruler",
"Sister",
"Spirit",
"Virgin"
],
animal: [
"Antelope",
"Ape",
"Badger",
"Basilisk",
"Bear",
"Beaver",
"Bison",
"Boar",
"Buffalo",
"Camel",
"Cat",
"Centaur",
"Chimera",
"Cobra",
"Crane",
"Crocodile",
"Crow",
"Cyclope",
"Deer",
"Dog",
"Dragon",
"Eagle",
"Elk",
"Falcon",
"Fox",
"Goat",
"Goose",
"Hare",
"Hawk",
"Heron",
"Horse",
"Hound",
"Hyena",
"Ibis",
"Jackal",
"Jaguar",
"Kraken",
"Lark",
"Leopard",
"Lion",
"Mantis",
"Marten",
"Moose",
"Mule",
"Narwhal",
"Owl",
"Ox",
"Panther",
"Pegasus",
"Phoenix",
"Rat",
"Raven",
"Rook",
"Scorpion",
"Serpent",
"Shark",
"Sheep",
"Snake",
"Sphinx",
"Spider",
"Swan",
"Tiger",
"Turtle",
"Unicorn",
"Viper",
"Vulture",
"Walrus",
"Wolf",
"Wolverine",
"Worm",
"Wyvern"
],
adjective: [
"Aggressive",
"Almighty",
"Ancient",
"Beautiful",
"Benevolent",
"Big",
"Blind",
"Blond",
"Bloody",
"Brave",
"Broken",
"Brutal",
"Burning",
"Calm",
"Cheerful",
"Crazy",
"Cruel",
"Dead",
"Deadly",
"Devastating",
"Distant",
"Disturbing",
"Divine",
"Dying",
"Eternal",
"Evil",
"Explicit",
"Fair",
"Far",
"Fat",
"Fatal",
"Favorable",
"Flying",
"Friendly",
"Frozen",
"Giant",
"Good",
"Grateful",
"Great",
"Happy",
"High",
"Holy",
"Honest",
"Huge",
"Hungry",
"Immutable",
"Infallible",
"Inherent",
"Last",
"Latter",
"Lost",
"Loud",
"Lucky",
"Mad",
"Magical",
"Main",
"Major",
"Marine",
"Naval",
"New",
"Old",
"Patient",
"Peaceful",
"Pregnant",
"Prime",
"Proud",
"Pure",
"Sacred",
"Sad",
"Scary",
"Secret",
"Selected",
"Severe",
"Silent",
"Sleeping",
"Slumbering",
"Strong",
"Sunny",
"Superior",
"Sustainable",
"Troubled",
"Unhappy",
"Unknown",
"Waking",
"Wild",
"Wise",
"Worried",
"Young"
],
genitive: [
"Cold",
"Day",
"Death",
"Doom",
"Fate",
"Fire",
"Fog",
"Frost",
"Gates",
"Heaven",
"Home",
"Ice",
"Justice",
"Life",
"Light",
"Lightning",
"Love",
"Nature",
"Night",
"Pain",
"Snow",
"Springs",
"Summer",
"Thunder",
"Time",
"Victory",
"War",
"Winter"
],
theGenitive: [
"Abyss",
"Blood",
"Dawn",
"Earth",
"East",
"Eclipse",
"Fall",
"Harvest",
"Moon",
"North",
"Peak",
"Rainbow",
"Sea",
"Sky",
"South",
"Stars",
"Storm",
"Sun",
"Tree",
"Underworld",
"West",
"Wild",
"Word",
"World"
],
color: [
"Amber",
"Black",
"Blue",
"Bright",
"Brown",
"Dark",
"Golden",
"Green",
"Grey",
"Light",
"Orange",
"Pink",
"Purple",
"Red",
"White",
"Yellow"
]
};
const forms = {
Folk: {Shamanism: 2, Animism: 2, "Ancestor worship": 1, Polytheism: 2},
Organized: {Polytheism: 5, Dualism: 1, Monotheism: 4, "Non-theism": 1},
Cult: {Cult: 1, "Dark Cult": 1},
Heresy: {Heresy: 1}
};
const methods = {
"Random + type": 3,
"Random + ism": 1,
"Supreme + ism": 5,
"Faith of + Supreme": 5,
"Place + ism": 1,
"Culture + ism": 2,
"Place + ian + type": 6,
"Culture + type": 4
};
const types = {
Shamanism: {Beliefs: 3, Shamanism: 2, Spirits: 1},
Animism: {Spirits: 1, Beliefs: 1},
"Ancestor worship": {Beliefs: 1, Forefathers: 2, Ancestors: 2},
Polytheism: {Deities: 3, Faith: 1, Gods: 1, Pantheon: 1},
Dualism: {Religion: 3, Faith: 1, Cult: 1},
Monotheism: {Religion: 1, Church: 1},
"Non-theism": {Beliefs: 3, Spirits: 1},
Cult: {Cult: 4, Sect: 4, Arcanum: 1, Coterie: 1, Order: 1, Worship: 1},
"Dark Cult": {Cult: 2, Sect: 2, Blasphemy: 1, Circle: 1, Coven: 1, Idols: 1, Occultism: 1},
Heresy: {
Heresy: 3,
Sect: 2,
Apostates: 1,
Brotherhood: 1,
Circle: 1,
Dissent: 1,
Dissenters: 1,
Iconoclasm: 1,
Schism: 1,
Society: 1
}
};
const generate = function () {
TIME && console.time("generateReligions");
const cells = pack.cells,
states = pack.states,
cultures = pack.cultures;
const religions = (pack.religions = []);
cells.religion = new Uint16Array(cells.culture); // cell religion; initially based on culture
// add folk religions
pack.cultures.forEach(c => {
if (!c.i) return religions.push({i: 0, name: "No religion"});
if (c.removed) {
religions.push({
i: c.i,
name: "Extinct religion for " + c.name,
color: getMixedColor(c.color, 0.1, 0),
removed: true
});
return;
}
const form = rw(forms.Folk);
const name = c.name + " " + rw(types[form]);
const deity = form === "Animism" ? null : getDeityName(c.i);
const color = getMixedColor(c.color, 0.1, 0); // `url(#hatch${rand(8,13)})`;
religions.push({i: c.i, name, color, culture: c.i, type: "Folk", form, deity, center: c.center, origins: [0]});
});
if (religionsInput.value == 0 || pack.cultures.length < 2)
return religions.filter(r => r.i).forEach(r => (r.code = abbreviate(r.name)));
const burgs = pack.burgs.filter(b => b.i && !b.removed);
const sorted =
burgs.length > +religionsInput.value
? burgs.sort((a, b) => b.population - a.population).map(b => b.cell)
: cells.i.filter(i => cells.s[i] > 2).sort((a, b) => cells.s[b] - cells.s[a]);
const religionsTree = d3.quadtree();
const spacing = (graphWidth + graphHeight) / 6 / religionsInput.value; // base min distance between towns
const cultsCount = Math.floor((rand(10, 40) / 100) * religionsInput.value);
const count = +religionsInput.value - cultsCount + religions.length;
function getReligionsInRadius({x, y, r, max}) {
if (max === 0) return [0];
const cellsInRadius = findAll(x, y, r);
const religions = unique(cellsInRadius.map(i => cells.religion[i]).filter(r => r));
return religions.length ? religions.slice(0, max) : [0];
}
// generate organized religions
for (let i = 0; religions.length < count && i < 1000; i++) {
let center = sorted[biased(0, sorted.length - 1, 5)]; // religion center
const form = rw(forms.Organized);
const state = cells.state[center];
const culture = cells.culture[center];
const deity = form === "Non-theism" ? null : getDeityName(culture);
let [name, expansion] = getReligionName(form, deity, center);
if (expansion === "state" && !state) expansion = "global";
if (expansion === "culture" && !culture) expansion = "global";
if (expansion === "state" && Math.random() > 0.5) center = states[state].center;
if (expansion === "culture" && Math.random() > 0.5) center = cultures[culture].center;
if (!cells.burg[center] && cells.c[center].some(c => cells.burg[c]))
center = cells.c[center].find(c => cells.burg[c]);
const [x, y] = cells.p[center];
const s = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make the placement not uniform
if (religionsTree.find(x, y, s) !== undefined) continue; // to close to existing religion
// add "Old" to name of the folk religion on this culture
const isFolkBased = expansion === "culture" || P(0.5);
const folk = isFolkBased && religions.find(r => r.culture === culture && r.type === "Folk");
if (folk && expansion === "culture" && folk.name.slice(0, 3) !== "Old") folk.name = "Old " + folk.name;
const origins = folk ? [folk.i] : getReligionsInRadius({x, y, r: 150 / count, max: 2});
const expansionism = rand(3, 8);
const baseColor = religions[culture]?.color || states[state]?.color || getRandomColor();
const color = getMixedColor(baseColor, 0.3, 0);
religions.push({
i: religions.length,
name,
color,
culture,
type: "Organized",
form,
deity,
expansion,
expansionism,
center,
origins
});
religionsTree.add([x, y]);
}
// generate cults
for (let i = 0; religions.length < count + cultsCount && i < 1000; i++) {
const form = rw(forms.Cult);
let center = sorted[biased(0, sorted.length - 1, 1)]; // religion center
if (!cells.burg[center] && cells.c[center].some(c => cells.burg[c]))
center = cells.c[center].find(c => cells.burg[c]);
const [x, y] = cells.p[center];
const s = spacing * gauss(2, 0.3, 1, 3, 2); // randomize to make the placement not uniform
if (religionsTree.find(x, y, s) !== undefined) continue; // to close to existing religion
const culture = cells.culture[center];
const origins = getReligionsInRadius({x, y, r: 300 / count, max: rand(0, 4)});
const deity = getDeityName(culture);
const name = getCultName(form, center);
const expansionism = gauss(1.1, 0.5, 0, 5);
const color = getMixedColor(cultures[culture].color, 0.5, 0); // "url(#hatch7)";
religions.push({
i: religions.length,
name,
color,
culture,
type: "Cult",
form,
deity,
expansion: "global",
expansionism,
center,
origins
});
religionsTree.add([x, y]);
}
expandReligions();
// generate heresies
religions
.filter(r => r.type === "Organized")
.forEach(r => {
if (r.expansionism < 3) return;
const count = gauss(0, 1, 0, 3);
for (let i = 0; i < count; i++) {
let center = ra(
cells.i.filter(i => cells.religion[i] === r.i && cells.c[i].some(c => cells.religion[c] !== r.i))
);
if (!center) continue;
if (!cells.burg[center] && cells.c[center].some(c => cells.burg[c]))
center = cells.c[center].find(c => cells.burg[c]);
const [x, y] = cells.p[center];
if (religionsTree.find(x, y, spacing / 10) !== undefined) continue; // to close to other
const culture = cells.culture[center];
const name = getCultName("Heresy", center);
const expansionism = gauss(1.2, 0.5, 0, 5);
const color = getMixedColor(r.color, 0.4, 0.2); // "url(#hatch6)";
religions.push({
i: religions.length,
name,
color,
culture,
type: "Heresy",
form: r.form,
deity: r.deity,
expansion: "global",
expansionism,
center,
origins: [r.i]
});
religionsTree.add([x, y]);
}
});
expandHeresies();
checkCenters();
TIME && console.timeEnd("generateReligions");
};
const add = function (center) {
const {cells, religions} = pack;
const religionId = cells.religion[center];
const culture = cells.culture[center];
const color = getMixedColor(religions[religionId].color, 0.3, 0);
const type =
religions[religionId].type === "Organized" ? rw({Organized: 4, Cult: 1, Heresy: 2}) : rw({Organized: 5, Cult: 2});
const form = rw(forms[type]);
const deity =
type === "Heresy" ? religions[religionId].deity : form === "Non-theism" ? null : getDeityName(culture);
let name, expansion;
if (type === "Organized") [name, expansion] = getReligionName(form, deity, center);
else {
name = getCultName(form, center);
expansion = "global";
}
const formName = type === "Heresy" ? religions[religionId].form : form;
const code = abbreviate(
name,
religions.map(r => r.code)
);
const i = religions.length;
religions.push({
i,
name,
color,
culture,
type,
form: formName,
deity,
expansion,
expansionism: 0,
center,
cells: 0,
area: 0,
rural: 0,
urban: 0,
origins: [religionId],
code
});
cells.religion[center] = i;
};
// growth algorithm to assign cells to religions
const expandReligions = function () {
const cells = pack.cells,
religions = pack.religions;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [];
religions
.filter(r => r.type === "Organized" || r.type === "Cult")
.forEach(r => {
cells.religion[r.center] = r.i;
queue.queue({e: r.center, p: 0, r: r.i, s: cells.state[r.center], c: r.culture});
cost[r.center] = 1;
});
const neutral = (cells.i.length / 5000) * 200 * gauss(1, 0.3, 0.2, 2, 2) * neutralInput.value; // limit cost for organized religions growth
const popCost = d3.max(cells.pop) / 3; // enougth population to spered religion without penalty
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p,
r = next.r,
c = next.c,
s = next.s;
const expansion = religions[r].expansion;
cells.c[n].forEach(function (e) {
if (expansion === "culture" && c !== cells.culture[e]) return;
if (expansion === "state" && s !== cells.state[e]) return;
const cultureCost = c !== cells.culture[e] ? 10 : 0;
const stateCost = s !== cells.state[e] ? 10 : 0;
const biomeCost = cells.road[e] ? 1 : biomesData.cost[cells.biome[e]];
const populationCost = Math.max(rn(popCost - cells.pop[e]), 0);
const heightCost = Math.max(cells.h[e], 20) - 20;
const waterCost = cells.h[e] < 20 ? (cells.road[e] ? 50 : 1000) : 0;
const totalCost =
p +
(cultureCost + stateCost + biomeCost + populationCost + heightCost + waterCost) / religions[r].expansionism;
if (totalCost > neutral) return;
if (!cost[e] || totalCost < cost[e]) {
if (cells.h[e] >= 20 && cells.culture[e]) cells.religion[e] = r; // assign religion to cell
cost[e] = totalCost;
queue.queue({e, p: totalCost, r, c, s});
}
});
}
};
// growth algorithm to assign cells to heresies
const expandHeresies = function () {
const cells = pack.cells,
religions = pack.religions;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [];
religions
.filter(r => r.type === "Heresy")
.forEach(r => {
const b = cells.religion[r.center]; // "base" religion id
cells.religion[r.center] = r.i; // heresy id
queue.queue({e: r.center, p: 0, r: r.i, b});
cost[r.center] = 1;
});
const neutral = (cells.i.length / 5000) * 500 * neutralInput.value; // limit cost for heresies growth
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p,
r = next.r,
b = next.b;
cells.c[n].forEach(function (e) {
const religionCost = cells.religion[e] === b ? 0 : 2000;
const biomeCost = cells.road[e] ? 0 : biomesData.cost[cells.biome[e]];
const heightCost = Math.max(cells.h[e], 20) - 20;
const waterCost = cells.h[e] < 20 ? (cells.road[e] ? 50 : 1000) : 0;
const totalCost =
p + (religionCost + biomeCost + heightCost + waterCost) / Math.max(religions[r].expansionism, 0.1);
if (totalCost > neutral) return;
if (!cost[e] || totalCost < cost[e]) {
if (cells.h[e] >= 20 && cells.culture[e]) cells.religion[e] = r; // assign religion to cell
cost[e] = totalCost;
queue.queue({e, p: totalCost, r});
}
});
}
};
function checkCenters() {
const {cells, religions} = pack;
const codes = religions.map(r => r.code);
religions.forEach(r => {
if (!r.i) return;
r.code = abbreviate(r.name, codes);
// move religion center if it's not within religion area after expansion
if (cells.religion[r.center] === r.i) return; // in area
const religCells = cells.i.filter(i => cells.religion[i] === r.i);
if (!religCells.length) return; // extinct religion
r.center = religCells.sort((a, b) => cells.pop[b] - cells.pop[a])[0];
});
}
function updateCultures() {
TIME && console.time("updateCulturesForReligions");
pack.religions = pack.religions.map((religion, index) => {
if (index === 0) {
return religion;
}
return {...religion, culture: pack.cells.culture[religion.center]};
});
TIME && console.timeEnd("updateCulturesForReligions");
}
// get supreme deity name
const getDeityName = function (culture) {
if (culture === undefined) {
ERROR && console.error("Please define a culture");
return;
}
const meaning = generateMeaning();
const cultureName = Names.getCulture(culture, null, null, "", 0.8);
return cultureName + ", The " + meaning;
};
function generateMeaning() {
const a = ra(approaches); // select generation approach
if (a === "Number") return ra(base.number);
if (a === "Being") return ra(base.being);
if (a === "Adjective") return ra(base.adjective);
if (a === "Color + Animal") return ra(base.color) + " " + ra(base.animal);
if (a === "Adjective + Animal") return ra(base.adjective) + " " + ra(base.animal);
if (a === "Adjective + Being") return ra(base.adjective) + " " + ra(base.being);
if (a === "Adjective + Genitive") return ra(base.adjective) + " " + ra(base.genitive);
if (a === "Color + Being") return ra(base.color) + " " + ra(base.being);
if (a === "Color + Genitive") return ra(base.color) + " " + ra(base.genitive);
if (a === "Being + of + Genitive") return ra(base.being) + " of " + ra(base.genitive);
if (a === "Being + of the + Genitive") return ra(base.being) + " of the " + ra(base.theGenitive);
if (a === "Animal + of + Genitive") return ra(base.animal) + " of " + ra(base.genitive);
if (a === "Adjective + Being + of + Genitive")
return ra(base.adjective) + " " + ra(base.being) + " of " + ra(base.genitive);
if (a === "Adjective + Animal + of + Genitive")
return ra(base.adjective) + " " + ra(base.animal) + " of " + ra(base.genitive);
}
function getReligionName(form, deity, center) {
const {cells, cultures, burgs, states} = pack;
const random = () => Names.getCulture(cells.culture[center], null, null, "", 0);
const type = () => rw(types[form]);
const supreme = () => deity.split(/[ ,]+/)[0];
const culture = () => cultures[cells.culture[center]].name;
const place = adj => {
const burgId = cells.burg[center];
const stateId = cells.state[center];
const base = burgId ? burgs[burgId].name : states[stateId].name;
let name = trimVowels(base.split(/[ ,]+/)[0]);
return adj ? getAdjective(name) : name;
};
const m = rw(methods);
if (m === "Random + type") return [random() + " " + type(), "global"];
if (m === "Random + ism") return [trimVowels(random()) + "ism", "global"];
if (m === "Supreme + ism" && deity) return [trimVowels(supreme()) + "ism", "global"];
if (m === "Faith of + Supreme" && deity)
return [ra(["Faith", "Way", "Path", "Word", "Witnesses"]) + " of " + supreme(), "global"];
if (m === "Place + ism") return [place() + "ism", "state"];
if (m === "Culture + ism") return [trimVowels(culture()) + "ism", "culture"];
if (m === "Place + ian + type") return [place("adj") + " " + type(), "state"];
if (m === "Culture + type") return [culture() + " " + type(), "culture"];
return [trimVowels(random()) + "ism", "global"]; // else
}
function getCultName(form, center) {
const cells = pack.cells;
const type = function () {
return rw(types[form]);
};
const random = function () {
return trimVowels(Names.getCulture(cells.culture[center], null, null, "", 0).split(/[ ,]+/)[0]);
};
const burg = function () {
return trimVowels(pack.burgs[cells.burg[center]].name.split(/[ ,]+/)[0]);
};
if (cells.burg[center]) return burg() + "ian " + type();
if (Math.random() > 0.5) return random() + "ian " + type();
return type() + " of the " + generateMeaning();
}
return {generate, add, getDeityName, expandReligions, updateCultures};
})();

View file

@ -0,0 +1,514 @@
import {TIME} from "/src/config/logging";
import {last} from "/src/utils/arrayUtils";
import {rn} from "/src/utils/numberUtils";
import {round} from "/src/utils/stringUtils";
window.Rivers = (function () {
const generate = function (allowErosion = true) {
TIME && console.time("generateRivers");
Math.random = aleaPRNG(seed);
const {cells, features} = pack;
const riversData = {}; // rivers data
const riverParents = {};
const addCellToRiver = function (cell, river) {
if (!riversData[river]) riversData[river] = [cell];
else riversData[river].push(cell);
};
cells.fl = new Uint16Array(cells.i.length); // water flux array
cells.r = new Uint16Array(cells.i.length); // rivers array
cells.conf = new Uint8Array(cells.i.length); // confluences array
let riverNext = 1; // first river id is 1
const h = alterHeights();
Lakes.prepareLakeData(h);
resolveDepressions(h);
drainWater();
defineRivers();
calculateConfluenceFlux();
Lakes.cleanupLakeData();
if (allowErosion) {
cells.h = Uint8Array.from(h); // apply gradient
downcutRivers(); // downcut river beds
}
TIME && console.timeEnd("generateRivers");
function drainWater() {
//const MIN_FLUX_TO_FORM_RIVER = 10 * distanceScale;
const MIN_FLUX_TO_FORM_RIVER = 30;
const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25;
const prec = grid.cells.prec;
const area = pack.cells.area;
const land = cells.i.filter(i => h[i] >= 20).sort((a, b) => h[b] - h[a]);
const lakeOutCells = Lakes.setClimateData(h);
land.forEach(function (i) {
cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation
// create lake outlet if lake is not in deep depression and flux > evaporation
const lakes = lakeOutCells[i]
? features.filter(feature => i === feature.outCell && feature.flux > feature.evaporation)
: [];
for (const lake of lakes) {
const lakeCell = cells.c[i].find(c => h[c] < 20 && cells.f[c] === lake.i);
cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet
// allow chain lakes to retain identity
if (cells.r[lakeCell] !== lake.river) {
const sameRiver = cells.c[lakeCell].some(c => cells.r[c] === lake.river);
if (sameRiver) {
cells.r[lakeCell] = lake.river;
addCellToRiver(lakeCell, lake.river);
} else {
cells.r[lakeCell] = riverNext;
addCellToRiver(lakeCell, riverNext);
riverNext++;
}
}
lake.outlet = cells.r[lakeCell];
flowDown(i, cells.fl[lakeCell], lake.outlet);
}
// assign all tributary rivers to outlet basin
const outlet = lakes[0]?.outlet;
for (const lake of lakes) {
if (!Array.isArray(lake.inlets)) continue;
for (const inlet of lake.inlets) {
riverParents[inlet] = outlet;
}
}
// near-border cell: pour water out of the screen
if (cells.b[i] && cells.r[i]) return addCellToRiver(-1, cells.r[i]);
// downhill cell (make sure it's not in the source lake)
let min = null;
if (lakeOutCells[i]) {
const filtered = cells.c[i].filter(c => !lakes.map(lake => lake.i).includes(cells.f[c]));
min = filtered.sort((a, b) => h[a] - h[b])[0];
} else if (cells.haven[i]) {
min = cells.haven[i];
} else {
min = cells.c[i].sort((a, b) => h[a] - h[b])[0];
}
// cells is depressed
if (h[i] <= h[min]) return;
// debug
// .append("line")
// .attr("x1", pack.cells.p[i][0])
// .attr("y1", pack.cells.p[i][1])
// .attr("x2", pack.cells.p[min][0])
// .attr("y2", pack.cells.p[min][1])
// .attr("stroke", "#333")
// .attr("stroke-width", 0.2);
if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) {
// flux is too small to operate as a river
if (h[min] >= 20) cells.fl[min] += cells.fl[i];
return;
}
// proclaim a new river
if (!cells.r[i]) {
cells.r[i] = riverNext;
addCellToRiver(i, riverNext);
riverNext++;
}
flowDown(min, cells.fl[i], cells.r[i]);
});
}
function flowDown(toCell, fromFlux, river) {
const toFlux = cells.fl[toCell] - cells.conf[toCell];
const toRiver = cells.r[toCell];
if (toRiver) {
// downhill cell already has river assigned
if (fromFlux > toFlux) {
cells.conf[toCell] += cells.fl[toCell]; // mark confluence
if (h[toCell] >= 20) riverParents[toRiver] = river; // min river is a tributary of current river
cells.r[toCell] = river; // re-assign river if downhill part has less flux
} else {
cells.conf[toCell] += fromFlux; // mark confluence
if (h[toCell] >= 20) riverParents[river] = toRiver; // current river is a tributary of min river
}
} else cells.r[toCell] = river; // assign the river to the downhill cell
if (h[toCell] < 20) {
// pour water to the water body
const waterBody = features[cells.f[toCell]];
if (waterBody.type === "lake") {
if (!waterBody.river || fromFlux > waterBody.enteringFlux) {
waterBody.river = river;
waterBody.enteringFlux = fromFlux;
}
waterBody.flux = waterBody.flux + fromFlux;
if (!waterBody.inlets) waterBody.inlets = [river];
else waterBody.inlets.push(river);
}
} else {
// propagate flux and add next river segment
cells.fl[toCell] += fromFlux;
}
addCellToRiver(toCell, river);
}
function defineRivers() {
// re-initialize rivers and confluence arrays
cells.r = new Uint16Array(cells.i.length);
cells.conf = new Uint16Array(cells.i.length);
pack.rivers = [];
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const mainStemWidthFactor = defaultWidthFactor * 1.2;
for (const key in riversData) {
const riverCells = riversData[key];
if (riverCells.length < 3) continue; // exclude tiny rivers
const riverId = +key;
for (const cell of riverCells) {
if (cell < 0 || cells.h[cell] < 20) continue;
// mark real confluences and assign river to cells
if (cells.r[cell]) cells.conf[cell] = 1;
else cells.r[cell] = riverId;
}
const source = riverCells[0];
const mouth = riverCells[riverCells.length - 2];
const parent = riverParents[key] || 0;
const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor;
const meanderedPoints = addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, 0));
pack.rivers.push({
i: riverId,
source,
mouth,
discharge,
length,
width,
widthFactor,
sourceWidth: 0,
parent,
cells: riverCells
});
}
}
function downcutRivers() {
const MAX_DOWNCUT = 5;
for (const i of pack.cells.i) {
if (cells.h[i] < 35) continue; // don't donwcut lowlands
if (!cells.fl[i]) continue;
const higherCells = cells.c[i].filter(c => cells.h[c] > cells.h[i]);
const higherFlux = higherCells.reduce((acc, c) => acc + cells.fl[c], 0) / higherCells.length;
if (!higherFlux) continue;
const downcut = Math.floor(cells.fl[i] / higherFlux);
if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT);
}
}
function calculateConfluenceFlux() {
for (const i of cells.i) {
if (!cells.conf[i]) continue;
const sortedInflux = cells.c[i]
.filter(c => cells.r[c] && h[c] > h[i])
.map(c => cells.fl[c])
.sort((a, b) => b - a);
cells.conf[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0);
}
}
};
// add distance to water value to land cells to make map less depressed
const alterHeights = () => {
const {h, c, t} = pack.cells;
return Array.from(h).map((h, i) => {
if (h < 20 || t[i] < 1) return h;
return h + t[i] / 100 + d3.mean(c[i].map(c => t[c])) / 10000;
});
};
// depression filling algorithm (for a correct water flux modeling)
const resolveDepressions = function (h) {
const {cells, features} = pack;
const maxIterations = +document.getElementById("resolveDepressionsStepsOutput").value;
const checkLakeMaxIteration = maxIterations * 0.85;
const elevateLakeMaxIteration = maxIterations * 0.75;
const height = i => features[cells.f[i]].height || h[i]; // height of lake or specific cell
const lakes = features.filter(f => f.type === "lake");
const land = cells.i.filter(i => h[i] >= 20 && !cells.b[i]); // exclude near-border cells
land.sort((a, b) => h[a] - h[b]); // lowest cells go first
const progress = [];
let depressions = Infinity;
let prevDepressions = null;
for (let iteration = 0; depressions && iteration < maxIterations; iteration++) {
if (progress.length > 5 && d3.sum(progress) > 0) {
// bad progress, abort and set heights back
h = alterHeights();
depressions = progress[0];
break;
}
depressions = 0;
if (iteration < checkLakeMaxIteration) {
for (const l of lakes) {
if (l.closed) continue;
const minHeight = d3.min(l.shoreline.map(s => h[s]));
if (minHeight >= 100 || l.height > minHeight) continue;
if (iteration > elevateLakeMaxIteration) {
l.shoreline.forEach(i => (h[i] = cells.h[i]));
l.height = d3.min(l.shoreline.map(s => h[s])) - 1;
l.closed = true;
continue;
}
depressions++;
l.height = minHeight + 0.2;
}
}
for (const i of land) {
const minHeight = d3.min(cells.c[i].map(c => height(c)));
if (minHeight >= 100 || h[i] > minHeight) continue;
depressions++;
h[i] = minHeight + 0.1;
}
prevDepressions !== null && progress.push(depressions - prevDepressions);
prevDepressions = depressions;
}
depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`);
};
// add points at 1/3 and 2/3 of a line between adjacents river cells
const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) {
const {fl, conf, h} = pack.cells;
const meandered = [];
const lastStep = riverCells.length - 1;
const points = getRiverPoints(riverCells, riverPoints);
let step = h[riverCells[0]] < 20 ? 1 : 10;
let fluxPrev = 0;
const getFlux = (step, flux) => (step === lastStep ? fluxPrev : flux);
for (let i = 0; i <= lastStep; i++, step++) {
const cell = riverCells[i];
const isLastCell = i === lastStep;
const [x1, y1] = points[i];
const flux1 = getFlux(i, fl[cell]);
fluxPrev = flux1;
meandered.push([x1, y1, flux1]);
if (isLastCell) break;
const nextCell = riverCells[i + 1];
const [x2, y2] = points[i + 1];
if (nextCell === -1) {
meandered.push([x2, y2, fluxPrev]);
break;
}
const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells
if (dist2 <= 25 && riverCells.length >= 6) continue;
const flux2 = getFlux(i + 1, fl[nextCell]);
const keepInitialFlux = conf[nextCell] || flux1 === flux2;
const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0);
const angle = Math.atan2(y2 - y1, x2 - x1);
const sinMeander = Math.sin(angle) * meander;
const cosMeander = Math.cos(angle) * meander;
if (step < 10 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) {
// if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment
const p1x = (x1 * 2 + x2) / 3 + -sinMeander;
const p1y = (y1 * 2 + y2) / 3 + cosMeander;
const p2x = (x1 + x2 * 2) / 3 + sinMeander / 2;
const p2y = (y1 + y2 * 2) / 3 - cosMeander / 2;
const [p1fl, p2fl] = keepInitialFlux ? [flux1, flux1] : [(flux1 * 2 + flux2) / 3, (flux1 + flux2 * 2) / 3];
meandered.push([p1x, p1y, p1fl], [p2x, p2y, p2fl]);
} else if (dist2 > 25 || riverCells.length < 6) {
// if dist is medium or river is small add 1 extra middlepoint
const p1x = (x1 + x2) / 2 + -sinMeander;
const p1y = (y1 + y2) / 2 + cosMeander;
const p1fl = keepInitialFlux ? flux1 : (flux1 + flux2) / 2;
meandered.push([p1x, p1y, p1fl]);
}
}
return meandered;
};
const getRiverPoints = (riverCells, riverPoints) => {
if (riverPoints) return riverPoints;
const {p} = pack.cells;
return riverCells.map((cell, i) => {
if (cell === -1) return getBorderPoint(riverCells[i - 1]);
return p[cell];
});
};
const getBorderPoint = i => {
const [x, y] = pack.cells.p[i];
const min = Math.min(y, graphHeight - y, x, graphWidth - x);
if (min === y) return [x, 0];
else if (min === graphHeight - y) return [x, graphHeight];
else if (min === x) return [0, y];
return [graphWidth, y];
};
const FLUX_FACTOR = 500;
const MAX_FLUX_WIDTH = 2;
const LENGTH_FACTOR = 200;
const STEP_WIDTH = 1 / LENGTH_FACTOR;
const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR);
const MAX_PROGRESSION = last(LENGTH_PROGRESSION);
const getOffset = (flux, pointNumber, widthFactor, startingWidth = 0) => {
const fluxWidth = Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH);
const lengthWidth = pointNumber * STEP_WIDTH + (LENGTH_PROGRESSION[pointNumber] || MAX_PROGRESSION);
return widthFactor * (lengthWidth + fluxWidth) + startingWidth;
};
// build polygon from a list of points and calculated offset (width)
const getRiverPath = function (points, widthFactor, startingWidth = 0) {
const riverPointsLeft = [];
const riverPointsRight = [];
for (let p = 0; p < points.length; p++) {
const [x0, y0] = points[p - 1] || points[p];
const [x1, y1, flux] = points[p];
const [x2, y2] = points[p + 1] || points[p];
const offset = getOffset(flux, p, widthFactor, startingWidth);
const angle = Math.atan2(y0 - y2, x0 - x2);
const sinOffset = Math.sin(angle) * offset;
const cosOffset = Math.cos(angle) * offset;
riverPointsLeft.push([x1 - sinOffset, y1 + cosOffset]);
riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]);
}
const right = lineGen(riverPointsRight.reverse());
let left = lineGen(riverPointsLeft);
left = left.substring(left.indexOf("C"));
return round(right + left, 1);
};
const specify = function () {
const rivers = pack.rivers;
if (!rivers.length) return;
for (const river of rivers) {
river.basin = getBasin(river.i);
river.name = getName(river.mouth);
river.type = getType(river);
}
};
const getName = function (cell) {
return Names.getCulture(pack.cells.culture[cell]);
};
// weighted arrays of river type names
const riverTypes = {
main: {
big: {River: 1},
small: {Creek: 9, River: 3, Brook: 3, Stream: 1}
},
fork: {
big: {Fork: 1},
small: {Branch: 1}
}
};
let smallLength = null;
const getType = function ({i, length, parent}) {
if (smallLength === null) {
const threshold = Math.ceil(pack.rivers.length * 0.15);
smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold];
}
const isSmall = length < smallLength;
const isFork = each(3)(i) && parent && parent !== i;
return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]);
};
const getApproximateLength = points => {
const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0);
return rn(length, 2);
};
// Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m,
// Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m
const getWidth = offset => rn((offset / 1.5) ** 1.8, 2); // mouth width in km
// remove river and all its tributaries
const remove = function (id) {
const cells = pack.cells;
const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i);
riversToRemove.forEach(r => rivers.select("#river" + r).remove());
cells.r.forEach((r, i) => {
if (!r || !riversToRemove.includes(r)) return;
cells.r[i] = 0;
cells.fl[i] = grid.cells.prec[cells.g[i]];
cells.conf[i] = 0;
});
pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i));
};
const getBasin = function (r) {
const parent = pack.rivers.find(river => river.i === r)?.parent;
if (!parent || r === parent) return r;
return getBasin(parent);
};
return {
generate,
alterHeights,
resolveDepressions,
addMeandering,
getRiverPath,
specify,
getName,
getType,
getBasin,
getWidth,
getOffset,
getApproximateLength,
getRiverPoints,
remove
};
})();

View file

@ -0,0 +1,278 @@
import {TIME} from "/src/config/logging";
import {findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {round} from "/src/utils/stringUtils";
window.Routes = (function () {
const getRoads = function () {
TIME && console.time("generateMainRoads");
const cells = pack.cells;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population);
if (capitals.length < 2) return []; // not enough capitals to build main roads
const paths = []; // array to store path segments
for (const b of capitals) {
const connect = capitals.filter(c => c.feature === b.feature && c !== b);
for (const t of connect) {
const [from, exit] = findLandPath(b.cell, t.cell, true);
const segments = restorePath(b.cell, exit, "main", from);
segments.forEach(s => paths.push(s));
}
}
cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score
TIME && console.timeEnd("generateMainRoads");
return paths;
};
const getTrails = function () {
TIME && console.time("generateTrails");
const cells = pack.cells;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
if (burgs.length < 2) return []; // not enough burgs to build trails
let paths = []; // array to store path segments
for (const f of pack.features.filter(f => f.land)) {
const isle = burgs.filter(b => b.feature === f.i); // burgs on island
if (isle.length < 2) continue;
isle.forEach(function (b, i) {
let path = [];
if (!i) {
// build trail from the first burg on island
// to the farthest one on the same island or the closest road
const farthest = d3.scan(
isle,
(a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2)
);
const to = isle[farthest].cell;
if (cells.road[to]) return;
const [from, exit] = findLandPath(b.cell, to, true);
path = restorePath(b.cell, exit, "small", from);
} else {
// build trail from all other burgs to the closest road on the same island
if (cells.road[b.cell]) return;
const [from, exit] = findLandPath(b.cell, null, true);
if (exit === null) return;
path = restorePath(b.cell, exit, "small", from);
}
if (path) paths = paths.concat(path);
});
}
TIME && console.timeEnd("generateTrails");
return paths;
};
const getSearoutes = function () {
TIME && console.time("generateSearoutes");
const {cells, burgs, features} = pack;
const allPorts = burgs.filter(b => b.port > 0 && !b.removed);
if (!allPorts.length) return [];
const bodies = new Set(allPorts.map(b => b.port)); // water features with ports
let paths = []; // array to store path segments
const connected = []; // store cell id of connected burgs
bodies.forEach(f => {
const ports = allPorts.filter(b => b.port === f); // all ports on the same feature
if (!ports.length) return;
if (features[f]?.border) addOverseaRoute(f, ports[0]);
// get inner-map routes
for (let s = 0; s < ports.length; s++) {
const source = ports[s].cell;
if (connected[source]) continue;
for (let t = s + 1; t < ports.length; t++) {
const target = ports[t].cell;
if (connected[target]) continue;
const [from, exit, passable] = findOceanPath(target, source, true);
if (!passable) continue;
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
connected[source] = 1;
connected[target] = 1;
}
}
});
function addOverseaRoute(f, port) {
const {x, y, cell: source} = port;
const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y);
const [x1, y1] = [
[0, y],
[x, 0],
[graphWidth, y],
[x, graphHeight]
].sort((a, b) => dist(a) - dist(b))[0];
const target = findCell(x1, y1);
if (cells.f[target] === f && cells.h[target] < 20) {
const [from, exit, passable] = findOceanPath(target, source, true);
if (passable) {
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
last(path).push([x1, y1]);
}
}
}
TIME && console.timeEnd("generateSearoutes");
return paths;
};
const draw = function (main, small, water) {
TIME && console.time("drawRoutes");
const {cells, burgs} = pack;
const {burg, p} = cells;
const getBurgCoords = b => [burgs[b].x, burgs[b].y];
const getPathPoints = cells => cells.map(i => (Array.isArray(i) ? i : burg[i] ? getBurgCoords(burg[i]) : p[i]));
const getPath = segment => round(lineGen(getPathPoints(segment)), 1);
const getPathsHTML = (paths, type) =>
paths.map((path, i) => `<path id="${type}${i}" d="${getPath(path)}" />`).join("");
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
roads.html(getPathsHTML(main, "road"));
trails.html(getPathsHTML(small, "trail"));
lineGen.curve(d3.curveBundle.beta(1));
searoutes.html(getPathsHTML(water, "searoute"));
TIME && console.timeEnd("drawRoutes");
};
const regenerate = function () {
routes.selectAll("path").remove();
pack.cells.road = new Uint16Array(pack.cells.i.length);
pack.cells.crossroad = new Uint16Array(pack.cells.i.length);
const main = getRoads();
const small = getTrails();
const water = getSearoutes();
draw(main, small, water);
};
return {getRoads, getTrails, getSearoutes, draw, regenerate};
// Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null)
function findLandPath(start, exit = null, toRoad = null) {
const cells = pack.cells;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [],
from = [];
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p;
if (toRoad && cells.road[n]) return [from, n];
for (const c of cells.c[n]) {
if (cells.h[c] < 20) continue; // ignore water cells
const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state
const habitability = biomesData.habitability[cells.biome[c]];
if (!habitability) continue; // avoid inhabitable cells (eg. lava, glacier)
const habitedCost = habitability ? Math.max(100 - habitability, 0) : 400; // routes tend to lay within populated areas
const heightChangeCost = Math.abs(cells.h[c] - cells.h[n]) * 10; // routes tend to avoid elevation changes
const heightCost = cells.h[c] > 80 ? cells.h[c] : 0; // routes tend to avoid mountainous areas
const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost;
const totalCost = p + (cells.road[c] || cells.burg[c] ? cellCoast / 3 : cellCoast);
if (from[c] || totalCost >= cost[c]) continue;
from[c] = n;
if (c === exit) return [from, exit];
cost[c] = totalCost;
queue.queue({e: c, p: totalCost});
}
}
return [from, exit];
}
function restorePath(start, end, type, from) {
const cells = pack.cells;
const path = []; // to store all segments;
let segment = [],
current = end,
prev = end;
const score = type === "main" ? 5 : 1; // to increase road score at cell
if (type === "ocean" || !cells.road[prev]) segment.push(end);
if (!cells.road[prev]) cells.road[prev] = score;
for (let i = 0, limit = 1000; i < limit; i++) {
if (!from[current]) break;
current = from[current];
if (cells.road[current]) {
if (segment.length) {
segment.push(current);
path.push(segment);
if (segment[0] !== end) {
cells.road[segment[0]] += score;
cells.crossroad[segment[0]] += score;
}
if (current !== start) {
cells.road[current] += score;
cells.crossroad[current] += score;
}
}
segment = [];
prev = current;
} else {
if (prev) segment.push(prev);
prev = null;
segment.push(current);
}
cells.road[current] += score;
if (current === start) break;
}
if (segment.length > 1) path.push(segment);
return path;
}
// find water paths
function findOceanPath(start, exit = null, toRoute = null) {
const cells = pack.cells,
temp = grid.cells.temp;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [],
from = [];
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p;
if (toRoute && n !== start && cells.road[n]) return [from, n, true];
for (const c of cells.c[n]) {
if (c === exit) {
from[c] = n;
return [from, exit, true];
}
if (cells.h[c] >= 20) continue; // ignore land cells
if (temp[cells.g[c]] <= -5) continue; // ignore cells with term <= -5
const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2;
const totalCost = p + (cells.road[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100));
if (from[c] || totalCost >= cost[c]) continue;
(from[c] = n), (cost[c] = totalCost);
queue.queue({e: c, p: totalCost});
}
}
return [from, exit, false];
}
})();

411
src/modules/submap.js Normal file
View file

@ -0,0 +1,411 @@
import {findCell} from "/src/utils/graphUtils";
import {getMiddlePoint} from "/src/utils/lineUtils";
import {rn} from "/src/utils/numberUtils";
window.Submap = (function () {
const isWater = (pack, id) => pack.cells.h[id] < 20;
const inMap = (x, y) => x > 0 && x < graphWidth && y > 0 && y < graphHeight;
function resample(parentMap, options) {
/*
generate new map based on an existing one (resampling parentMap)
parentMap: {seed, grid, pack} from original map
options = {
projection: f(Number,Number)->[Number, Number]
function to calculate new coordinates
inverse: g(Number,Number)->[Number, Number]
inverse of f
depressRivers: Bool carve out riverbeds?
smoothHeightMap: Bool run smooth filter on heights
addLakesInDepressions: call FMG original funtion on heightmap
lockMarkers: Bool Auto lock all copied markers
lockBurgs: Bool Auto lock all copied burgs
}
*/
const projection = options.projection;
const inverse = options.inverse;
const stage = s => INFO && console.log("SUBMAP:", s);
const timeStart = performance.now();
invokeActiveZooming();
// copy seed
seed = parentMap.seed;
Math.random = aleaPRNG(seed);
INFO && console.group("SubMap with seed: " + seed);
DEBUG && console.log("Using Options:", options);
// create new grid
applyMapSize();
grid = generateGrid();
drawScaleBar(scale);
const resampler = (points, qtree, f) => {
for (const [i, [x, y]] of points.entries()) {
const [tx, ty] = inverse(x, y);
const oldid = qtree.find(tx, ty, Infinity)[2];
f(i, oldid);
}
};
stage("Resampling heightmap, temperature and precipitation.");
// resample heightmap from old WorldState
const n = grid.points.length;
grid.cells.h = new Uint8Array(n); // heightmap
grid.cells.temp = new Int8Array(n); // temperature
grid.cells.prec = new Uint8Array(n); // precipitation
const reverseGridMap = new Uint32Array(n); // cellmap from new -> oldcell
const oldGrid = parentMap.grid;
// build cache old -> [newcelllist]
const forwardGridMap = parentMap.grid.points.map(_ => []);
resampler(grid.points, parentMap.pack.cells.q, (id, oldid) => {
const cid = parentMap.pack.cells.g[oldid];
grid.cells.h[id] = oldGrid.cells.h[cid];
grid.cells.temp[id] = oldGrid.cells.temp[cid];
grid.cells.prec[id] = oldGrid.cells.prec[cid];
if (options.depressRivers) forwardGridMap[cid].push(id);
reverseGridMap[id] = cid;
});
// TODO: add smooth/noise function for h, temp, prec n times
// smooth heightmap
// smoothing should never change cell type (land->water or water->land)
if (options.smoothHeightMap) {
const gcells = grid.cells;
gcells.h.forEach((h, i) => {
const hs = gcells.c[i].map(c => gcells.h[c]);
hs.push(h);
gcells.h[i] = h >= 20 ? Math.max(d3.mean(hs), 20) : Math.min(d3.mean(hs), 19);
});
}
if (options.depressRivers) {
stage("Generating riverbeds.");
const rbeds = new Uint16Array(grid.cells.i.length);
// and erode riverbeds
parentMap.pack.rivers.forEach(r =>
r.cells.forEach(oldpc => {
if (oldpc < 0) return; // ignore out-of-map marker (-1)
const oldc = parentMap.pack.cells.g[oldpc];
const targetCells = forwardGridMap[oldc];
if (!targetCells) throw "TargetCell shouldn't be empty.";
targetCells.forEach(c => {
if (grid.cells.h[c] < 20) return;
rbeds[c] = 1;
});
})
);
// raise every land cell a bit except riverbeds
grid.cells.h.forEach((h, i) => {
if (rbeds[i] || h < 20) return;
grid.cells.h[i] = Math.min(h + 2, 100);
});
}
stage("Detect features, ocean and generating lakes.");
markFeatures();
markupGridOcean();
// Warning: addLakesInDeepDepressions can be very slow!
if (options.addLakesInDepressions) {
addLakesInDeepDepressions();
openNearSeaLakes();
}
OceanLayers();
calculateMapCoordinates();
// calculateTemperatures();
// generatePrecipitation();
stage("Cell cleanup.");
reGraph();
// remove misclassified cells
stage("Define coastline.");
drawCoastline();
/****************************************************/
/* Packed Graph */
/****************************************************/
const oldCells = parentMap.pack.cells;
// const reverseMap = new Map(); // cellmap from new -> oldcell
const forwardMap = parentMap.pack.cells.p.map(_ => []); // old -> [newcelllist]
const pn = pack.cells.i.length;
const cells = pack.cells;
cells.culture = new Uint16Array(pn);
cells.state = new Uint16Array(pn);
cells.burg = new Uint16Array(pn);
cells.religion = new Uint16Array(pn);
cells.road = new Uint16Array(pn);
cells.crossroad = new Uint16Array(pn);
cells.province = new Uint16Array(pn);
stage("Resampling culture, state and religion map.");
for (const [id, gridCellId] of cells.g.entries()) {
const oldGridId = reverseGridMap[gridCellId];
if (oldGridId === undefined) {
console.error("Can not find old cell id", reverseGridMap, "in", gridCellId);
continue;
}
// find old parent's children
const oldChildren = oldCells.i.filter(oid => oldCells.g[oid] == oldGridId);
let oldid; // matching cell on the original map
if (!oldChildren.length) {
// it *must* be a (deleted) deep ocean cell
if (!oldGrid.cells.h[oldGridId] < 20) {
console.error(`Warning, ${gridCellId} should be water cell, not ${oldGrid.cells.h[oldGridId]}`);
continue;
}
// find replacement: closest water cell
const [ox, oy] = cells.p[id];
const [tx, ty] = inverse(x, y);
oldid = oldCells.q.find(tx, ty, Infinity)[2];
if (!oldid) {
console.warn("Warning, no id found in quad", id, "parent", gridCellId);
continue;
}
} else {
// find closest children (packcell) on the parent map
const distance = x => (x[0] - cells.p[id][0]) ** 2 + (x[1] - cells.p[id][1]) ** 2;
let d = Infinity;
oldChildren.forEach(oid => {
// this should be always true, unless some algo modded the height!
if (isWater(parentMap.pack, oid) !== isWater(pack, id)) {
console.warn(`cell sank because of addLakesInDepressions: ${oid}`);
}
const [oldpx, oldpy] = oldCells.p[oid];
const nd = distance(projection(oldpx, oldpy));
if (isNaN(nd)) {
console.error("Distance is not a number!", "Old point:", oldpx, oldpy);
}
if (nd < d) [d, oldid] = [nd, oid];
});
if (oldid === undefined) {
console.warn("Warning, no match for", id, "(parent:", gridCellId, ")");
continue;
}
}
if (isWater(pack, id) !== isWater(parentMap.pack, oldid)) {
WARN && console.warn("Type discrepancy detected:", id, oldid, `${pack.cells.t[id]} != ${oldCells.t[oldid]}`);
}
cells.culture[id] = oldCells.culture[oldid];
cells.state[id] = oldCells.state[oldid];
cells.religion[id] = oldCells.religion[oldid];
cells.province[id] = oldCells.province[oldid];
// reverseMap.set(id, oldid)
forwardMap[oldid].push(id);
}
stage("Regenerating river network.");
Rivers.generate();
drawRivers();
Lakes.defineGroup();
// biome calculation based on (resampled) grid.cells.temp and prec
// it's safe to recalculate.
stage("Regenerating Biome.");
defineBiomes();
// recalculate suitability and population
// TODO: normalize according to the base-map
rankCells();
stage("Porting Cultures");
pack.cultures = parentMap.pack.cultures;
// fix culture centers
const validCultures = new Set(pack.cells.culture);
pack.cultures.forEach((c, i) => {
if (!i) return; // ignore wildlands
if (!validCultures.has(i)) {
c.removed = true;
c.center = null;
return;
}
const newCenters = forwardMap[c.center];
c.center = newCenters.length ? newCenters[0] : pack.cells.culture.findIndex(x => x === i);
});
stage("Porting and locking burgs.");
copyBurgs(parentMap, projection, options);
// transfer states, mark states without land as removed.
stage("Porting states.");
const validStates = new Set(pack.cells.state);
pack.states = parentMap.pack.states;
// keep valid states and neighbors only
pack.states.forEach((s, i) => {
if (!s.i || s.removed) return; // ignore removed and neutrals
if (!validStates.has(i)) s.removed = true;
s.neighbors = s.neighbors.filter(n => validStates.has(n));
// find center
s.center = pack.burgs[s.capital].cell
? pack.burgs[s.capital].cell // capital is the best bet
: pack.cells.state.findIndex(x => x === i); // otherwise use the first valid cell
});
// transfer provinces, mark provinces without land as removed.
stage("Porting provinces.");
const validProvinces = new Set(pack.cells.province);
pack.provinces = parentMap.pack.provinces;
// mark uneccesary provinces
pack.provinces.forEach((p, i) => {
if (!p || p.removed) return;
if (!validProvinces.has(i)) {
p.removed = true;
return;
}
const newCenters = forwardMap[p.center];
p.center = newCenters.length ? newCenters[0] : pack.cells.province.findIndex(x => x === i);
});
BurgsAndStates.drawBurgs();
stage("Regenerating road network.");
Routes.regenerate();
drawStates();
drawBorders();
BurgsAndStates.drawStateLabels();
Rivers.specify();
Lakes.generateName();
stage("Porting military.");
for (const s of pack.states) {
if (!s.military) continue;
for (const m of s.military) {
[m.x, m.y] = projection(m.x, m.y);
[m.bx, m.by] = projection(m.bx, m.by);
const cc = forwardMap[m.cell];
m.cell = cc && cc.length ? cc[0] : null;
}
s.military = s.military.filter(m => m.cell).map((m, i) => ({...m, i}));
}
Military.redraw();
stage("Copying markers.");
for (const m of pack.markers) {
const [x, y] = projection(m.x, m.y);
if (!inMap(x, y)) {
Markers.deleteMarker(m.i);
} else {
m.x = x;
m.y = y;
m.cell = findCell(x, y);
if (options.lockMarkers) m.lock = true;
}
}
if (layerIsOn("toggleMarkers")) drawMarkers();
stage("Redraw emblems.");
drawEmblems();
stage("Regenerating Zones.");
addZones();
Names.getMapName();
stage("Restoring Notes.");
notes = parentMap.notes;
stage("Submap done.");
WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`);
showStatistics();
INFO && console.groupEnd("Generated Map " + seed);
}
/* find the nearest cell accepted by filter f *and* having at
* least one *neighbor* fulfilling filter g, up to cell-distance `max`
* returns [cellid, neighbor] tuple or undefined if no such cell.
* accepts coordinates (x, y)
*/
const findNearest =
(f, g, max = 3) =>
(px, py) => {
const d2 = c => (px - pack.cells.p[c][0]) ** 2 + (py - pack.cells.p[c][0]) ** 2;
const startCell = findCell(px, py);
const tested = new Set([startCell]); // ignore analyzed cells
const kernel = (cs, level) => {
const [bestf, bestg] = cs.filter(f).reduce(
([cf, cg], c) => {
const neighbors = pack.cells.c[c];
const betterg = neighbors.filter(g).reduce((u, x) => (d2(x) < d2(u) ? x : u));
if (cf === undefined) return [c, betterg];
return betterg && d2(cf) < d2(c) ? [c, betterg] : [cf, cg];
},
[undefined, undefined]
);
if (bestf && bestg) return [bestf, bestg];
// no suitable pair found, retry with next ring
const targets = new Set(cs.map(c => pack.cells.c[c]).flat());
const ring = Array.from(targets).filter(nc => !tested.has(nc));
if (level >= max || !ring.length) return [undefined, undefined];
ring.forEach(c => tested.add(c));
return kernel(ring, level + 1);
};
const pair = kernel([startCell], 1);
return pair;
};
function copyBurgs(parentMap, projection, options) {
const cells = pack.cells;
pack.burgs = parentMap.pack.burgs;
// remap burgs to the best new cell
pack.burgs.forEach((b, id) => {
if (id == 0) return; // skip empty city of neturals
[b.x, b.y] = projection(b.x, b.y);
b.population = b.population * options.scale; // adjust for populationRate change
// disable out-of-map (removed) burgs
if (!inMap(b.x, b.y)) {
b.removed = true;
b.cell = null;
return;
}
const cityCell = findCell(b.x, b.y);
let searchFunc;
const isFreeLand = c => cells.t[c] === 1 && !cells.burg[c];
const nearCoast = c => cells.t[c] === -1;
// check if we need to relocate the burg
if (cells.burg[cityCell])
// already occupied
searchFunc = findNearest(isFreeLand, _ => true, 3);
if (isWater(pack, cityCell) || b.port)
// burg is in water or port
searchFunc = findNearest(isFreeLand, nearCoast, 6);
if (searchFunc) {
const [newCell, neighbor] = searchFunc(b.x, b.y);
if (!newCell) {
WARN && console.warn(`Can not relocate Burg: ${b.name} sunk and destroyed. :-(`);
b.cell = null;
b.removed = true;
return;
}
DEBUG && console.log(`Moving ${b.name} from ${cityCell} to ${newCell} near ${neighbor}.`);
[b.x, b.y] = b.port ? getMiddlePoint(newCell, neighbor) : cells.p[newCell];
if (b.port) b.port = cells.f[neighbor]; // copy feature number
b.cell = newCell;
if (b.port && !isWater(pack, neighbor)) console.error("betrayal! negihbor must be water!", b);
} else {
b.cell = cityCell;
}
if (!b.lock) b.lock = options.lockBurgs;
cells.burg[b.cell] = id;
});
}
// export
return {resample, findNearest};
})();

644
src/modules/ui/3d.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,937 @@
import {last} from "/src/utils/arrayUtils";
import {tip} from "/src/scripts/tooltips";
import {wiki} from "/src/utils/linkUtils";
import {rn, minmax} from "/src/utils/numberUtils";
import {rand, P, Pint} from "/src/utils/probabilityUtils";
import {capitalize} from "/src/utils/stringUtils";
import {getAdjective, list} from "/src/utils/languageUtils";
export class Battle {
constructor(attacker, defender) {
if (customization) return;
closeDialogs(".stable");
customization = 13; // enter customization to avoid unwanted dialog closing
Battle.prototype.context = this; // store context
this.iteration = 0;
this.x = defender.x;
this.y = defender.y;
this.cell = findCell(this.x, this.y);
this.attackers = {regiments: [], distances: [], morale: 100, casualties: 0, power: 0};
this.defenders = {regiments: [], distances: [], morale: 100, casualties: 0, power: 0};
this.addHeaders();
this.addRegiment("attackers", attacker);
this.addRegiment("defenders", defender);
this.place = this.definePlace();
this.defineType();
this.name = this.defineName();
this.randomize();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
this.getInitialMorale();
$("#battleScreen").dialog({
title: this.name,
resizable: false,
width: "fit-content",
position: {my: "center", at: "center", of: "#map"},
close: () => Battle.prototype.context.cancelResults()
});
if (fmg.modules.Battle) return;
fmg.modules.Battle = true;
// add listeners
document.getElementById("battleType").addEventListener("click", ev => this.toggleChange(ev));
document
.getElementById("battleType")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changeType(ev));
document
.getElementById("battleNameShow")
.addEventListener("click", () => Battle.prototype.context.showNameSection());
document
.getElementById("battleNamePlace")
.addEventListener("change", ev => (Battle.prototype.context.place = ev.target.value));
document.getElementById("battleNameFull").addEventListener("change", ev => Battle.prototype.context.changeName(ev));
document
.getElementById("battleNameCulture")
.addEventListener("click", () => Battle.prototype.context.generateName("culture"));
document
.getElementById("battleNameRandom")
.addEventListener("click", () => Battle.prototype.context.generateName("random"));
document.getElementById("battleNameHide").addEventListener("click", this.hideNameSection);
document.getElementById("battleAddRegiment").addEventListener("click", this.addSide);
document.getElementById("battleRoll").addEventListener("click", () => Battle.prototype.context.randomize());
document.getElementById("battleRun").addEventListener("click", () => Battle.prototype.context.run());
document.getElementById("battleApply").addEventListener("click", () => Battle.prototype.context.applyResults());
document.getElementById("battleCancel").addEventListener("click", () => Battle.prototype.context.cancelResults());
document.getElementById("battleWiki").addEventListener("click", () => wiki("Battle-Simulator"));
document.getElementById("battlePhase_attackers").addEventListener("click", ev => this.toggleChange(ev));
document
.getElementById("battlePhase_attackers")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
document.getElementById("battlePhase_defenders").addEventListener("click", ev => this.toggleChange(ev));
document
.getElementById("battlePhase_defenders")
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
document
.getElementById("battleDie_attackers")
.addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
document
.getElementById("battleDie_defenders")
.addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
}
defineType() {
const attacker = this.attackers.regiments[0];
const defender = this.defenders.regiments[0];
const getType = () => {
const typesA = Object.keys(attacker.u).map(name => options.military.find(u => u.name === name).type);
const typesD = Object.keys(defender.u).map(name => options.military.find(u => u.name === name).type);
if (attacker.n && defender.n) return "naval"; // attacker and defender are navals
if (typesA.every(t => t === "aviation") && typesD.every(t => t === "aviation")) return "air"; // if attackers and defender have only aviation units
if (attacker.n && !defender.n && typesA.some(t => t !== "naval")) return "landing"; // if attacked is naval with non-naval units and defender is not naval
if (!defender.n && pack.burgs[pack.cells.burg[this.cell]].walls) return "siege"; // defender is in walled town
if (P(0.1) && [5, 6, 7, 8, 9, 12].includes(pack.cells.biome[this.cell])) return "ambush"; // 20% if defenders are in forest or marshes
return "field";
};
this.type = getType();
this.setType();
}
setType() {
document.getElementById("battleType").className = "icon-button-" + this.type;
const sideSpecific = document.getElementById("battlePhases_" + this.type + "_attackers");
const attackers = sideSpecific
? sideSpecific.content
: document.getElementById("battlePhases_" + this.type).content;
const defenders = sideSpecific
? document.getElementById("battlePhases_" + this.type + "_defenders").content
: attackers;
document.getElementById("battlePhase_attackers").nextElementSibling.innerHTML = "";
document.getElementById("battlePhase_defenders").nextElementSibling.innerHTML = "";
document.getElementById("battlePhase_attackers").nextElementSibling.append(attackers.cloneNode(true));
document.getElementById("battlePhase_defenders").nextElementSibling.append(defenders.cloneNode(true));
}
definePlace() {
const cells = pack.cells,
i = this.cell;
const burg = cells.burg[i] ? pack.burgs[cells.burg[i]].name : null;
const getRiver = i => {
const river = pack.rivers.find(r => r.i === i);
return river.name + " " + river.type;
};
const river = !burg && cells.r[i] ? getRiver(cells.r[i]) : null;
const proper = burg || river ? null : Names.getCulture(cells.culture[this.cell]);
return burg ? burg : river ? river : proper;
}
defineName() {
if (this.type === "field") return "Battle of " + this.place;
if (this.type === "naval") return "Naval Battle of " + this.place;
if (this.type === "siege") return "Siege of " + this.place;
if (this.type === "ambush") return this.place + " Ambush";
if (this.type === "landing") return this.place + " Landing";
if (this.type === "air") return `${this.place} ${P(0.8) ? "Air Battle" : "Dogfight"}`;
}
getTypeName() {
if (this.type === "field") return "field battle";
if (this.type === "naval") return "naval battle";
if (this.type === "siege") return "siege";
if (this.type === "ambush") return "ambush";
if (this.type === "landing") return "landing";
if (this.type === "air") return "battle";
}
addHeaders() {
let headers = "<thead><tr><th></th><th></th>";
for (const u of options.military) {
const label = capitalize(u.name.replace(/_/g, " "));
headers += `<th data-tip="${label}">${u.icon}</th>`;
}
headers += "<th data-tip='Total military''>Total</th></tr></thead>";
battleAttackers.innerHTML = battleDefenders.innerHTML = headers;
}
addRegiment(side, regiment) {
regiment.casualties = Object.keys(regiment.u).reduce((a, b) => ((a[b] = 0), a), {});
regiment.survivors = Object.assign({}, regiment.u);
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">
<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>`;
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>`;
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>`;
}
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>`;
const div = side === "attackers" ? battleAttackers : battleDefenders;
div.innerHTML += body + initial + casualties + survivors + "</tbody>";
this[side].regiments.push(regiment);
this[side].distances.push(distance);
}
addSide() {
const body = document.getElementById("regimentSelectorBody");
const context = Battle.prototype.context;
const regiments = pack.states
.filter(s => s.military && !s.removed)
.map(s => s.military)
.flat();
const distance = reg =>
rn(Math.hypot(context.y - reg.y, context.x - reg.x) * distanceScaleInput.value) + " " + distanceUnitInput.value;
const isAdded = reg =>
context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
body.innerHTML = regiments
.map(r => {
const s = pack.states[r.state],
added = isAdded(r),
dist = added ? "0 " + distanceUnitInput.value : distance(r);
return `<div ${added ? "class='inactive'" : ""} data-s=${s.i} data-i=${r.i} data-state=${
s.name
} data-regiment=${r.name}
data-total=${r.a} data-distance=${dist} data-tip="Click to select regiment">
<svg width=".9em" height=".9em" style="margin-bottom:-1px; stroke: #333"><rect x="0" y="0" width="100%" height="100%" fill="${
s.color
}" ></svg>
<div style="width:6em">${s.name.slice(0, 11)}</div>
<div style="width:1.2em">${r.icon}</div>
<div style="width:13em">${r.name.slice(0, 24)}</div>
<div style="width:4em">${r.a}</div>
<div style="width:4em">${dist}</div>
</div>`;
})
.join("");
$("#regimentSelectorScreen").dialog({
resizable: false,
width: "fit-content",
title: "Add regiment to the battle",
position: {my: "left center", at: "right+10 center", of: "#battleScreen"},
close: addSideClosed,
buttons: {
"Add to attackers": () => addSideClicked("attackers"),
"Add to defenders": () => addSideClicked("defenders"),
Cancel: () => $("#regimentSelectorScreen").dialog("close")
}
});
applySorting(regimentSelectorHeader);
body.addEventListener("click", selectLine);
function selectLine(ev) {
if (ev.target.className === "inactive") {
tip("Regiment is already in the battle", false, "error");
return;
}
ev.target.classList.toggle("selected");
}
function addSideClicked(side) {
const selected = body.querySelectorAll(".selected");
if (!selected.length) {
tip("Please select a regiment first", false, "error");
return;
}
$("#regimentSelectorScreen").dialog("close");
selected.forEach(line => {
const state = pack.states[line.dataset.s];
const regiment = state.military.find(r => r.i == +line.dataset.i);
Battle.prototype.addRegiment.call(context, side, regiment);
Battle.prototype.calculateStrength.call(context, side);
Battle.prototype.getInitialMorale.call(context);
// move regiment
const defenders = context.defenders.regiments,
attackers = context.attackers.regiments;
const shift = side === "attackers" ? attackers.length * -8 : (defenders.length - 1) * 8;
regiment.px = regiment.x;
regiment.py = regiment.y;
Military.moveRegiment(regiment, defenders[0].x, defenders[0].y + shift);
});
}
function addSideClosed() {
body.innerHTML = "";
body.removeEventListener("click", selectLine);
}
}
showNameSection() {
document.querySelectorAll("#battleBottom > button").forEach(el => (el.style.display = "none"));
document.getElementById("battleNameSection").style.display = "inline-block";
document.getElementById("battleNamePlace").value = this.place;
document.getElementById("battleNameFull").value = this.name;
}
hideNameSection() {
document.querySelectorAll("#battleBottom > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("battleNameSection").style.display = "none";
}
changeName(ev) {
this.name = ev.target.value;
$("#battleScreen").dialog({title: this.name});
}
generateName(type) {
const place =
type === "culture"
? Names.getCulture(pack.cells.culture[this.cell], null, null, "")
: Names.getBase(rand(nameBases.length - 1));
document.getElementById("battleNamePlace").value = this.place = place;
document.getElementById("battleNameFull").value = this.name = this.defineName();
$("#battleScreen").dialog({title: this.name});
}
getJoinedForces(regiments) {
return regiments.reduce((a, b) => {
for (let k in b.survivors) {
if (!b.survivors.hasOwnProperty(k)) continue;
a[k] = (a[k] || 0) + b.survivors[k];
}
return a;
}, {});
}
calculateStrength(side) {
const scheme = {
// field battle phases
skirmish: {
melee: 0.2,
ranged: 2.4,
mounted: 0.1,
machinery: 3,
naval: 1,
armored: 0.2,
aviation: 1.8,
magical: 1.8
}, // ranged excel
melee: {melee: 2, ranged: 1.2, mounted: 1.5, machinery: 0.5, naval: 0.2, armored: 2, aviation: 0.8, magical: 0.8}, // melee excel
pursue: {melee: 1, ranged: 1, mounted: 4, machinery: 0.05, naval: 1, armored: 1, aviation: 1.5, magical: 0.6}, // mounted excel
retreat: {
melee: 0.1,
ranged: 0.01,
mounted: 0.5,
machinery: 0.01,
naval: 0.2,
armored: 0.1,
aviation: 0.8,
magical: 0.05
}, // reduced
// naval battle phases
shelling: {melee: 0, ranged: 0.2, mounted: 0, machinery: 2, naval: 2, armored: 0, aviation: 0.1, magical: 0.5}, // naval and machinery excel
boarding: {
melee: 1,
ranged: 0.5,
mounted: 0.5,
machinery: 0,
naval: 0.5,
armored: 0.4,
aviation: 0,
magical: 0.2
}, // melee excel
chase: {melee: 0, ranged: 0.15, mounted: 0, machinery: 1, naval: 1, armored: 0, aviation: 0.15, magical: 0.5}, // reduced
withdrawal: {
melee: 0,
ranged: 0.02,
mounted: 0,
machinery: 0.5,
naval: 0.1,
armored: 0,
aviation: 0.1,
magical: 0.3
}, // reduced
// siege phases
blockade: {
melee: 0.25,
ranged: 0.25,
mounted: 0.2,
machinery: 0.5,
naval: 0.2,
armored: 0.1,
aviation: 0.25,
magical: 0.25
}, // no active actions
sheltering: {
melee: 0.3,
ranged: 0.5,
mounted: 0.2,
machinery: 0.5,
naval: 0.2,
armored: 0.1,
aviation: 0.25,
magical: 0.25
}, // no active actions
sortie: {melee: 2, ranged: 0.5, mounted: 1.2, machinery: 0.2, naval: 0.1, armored: 0.5, aviation: 1, magical: 1}, // melee excel
bombardment: {
melee: 0.2,
ranged: 0.5,
mounted: 0.2,
machinery: 3,
naval: 1,
armored: 0.5,
aviation: 1,
magical: 1
}, // machinery excel
storming: {
melee: 1,
ranged: 0.6,
mounted: 0.5,
machinery: 1,
naval: 0.1,
armored: 0.1,
aviation: 0.5,
magical: 0.5
}, // melee excel
defense: {melee: 2, ranged: 3, mounted: 1, machinery: 1, naval: 0.1, armored: 1, aviation: 0.5, magical: 1}, // ranged excel
looting: {
melee: 1.6,
ranged: 1.6,
mounted: 0.5,
machinery: 0.2,
naval: 0.02,
armored: 0.2,
aviation: 0.1,
magical: 0.3
}, // melee excel
surrendering: {
melee: 0.1,
ranged: 0.1,
mounted: 0.05,
machinery: 0.01,
naval: 0.01,
armored: 0.02,
aviation: 0.01,
magical: 0.03
}, // reduced
// ambush phases
surprise: {melee: 2, ranged: 2.4, mounted: 1, machinery: 1, naval: 1, armored: 1, aviation: 0.8, magical: 1.2}, // increased
shock: {
melee: 0.5,
ranged: 0.5,
mounted: 0.5,
machinery: 0.4,
naval: 0.3,
armored: 0.1,
aviation: 0.4,
magical: 0.5
}, // reduced
// langing phases
landing: {
melee: 0.8,
ranged: 0.6,
mounted: 0.6,
machinery: 0.5,
naval: 0.5,
armored: 0.5,
aviation: 0.5,
magical: 0.6
}, // reduced
flee: {
melee: 0.1,
ranged: 0.01,
mounted: 0.5,
machinery: 0.01,
naval: 0.5,
armored: 0.1,
aviation: 0.2,
magical: 0.05
}, // reduced
waiting: {
melee: 0.05,
ranged: 0.5,
mounted: 0.05,
machinery: 0.5,
naval: 2,
armored: 0.05,
aviation: 0.5,
magical: 0.5
}, // reduced
// air battle phases
maneuvering: {melee: 0, ranged: 0.1, mounted: 0, machinery: 0.2, naval: 0, armored: 0, aviation: 1, magical: 0.2}, // aviation
dogfight: {melee: 0, ranged: 0.1, mounted: 0, machinery: 0.1, naval: 0, armored: 0, aviation: 2, magical: 0.1} // aviation
};
const forces = this.getJoinedForces(this[side].regiments);
const phase = this[side].phase;
const adjuster = Math.max(populationRate / 10, 10); // population adjuster, by default 100
this[side].power =
d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
const UIvalue = this[side].power ? Math.max(this[side].power | 0, 1) : 0;
document.getElementById("battlePower_" + side).innerHTML = UIvalue;
}
getInitialMorale() {
const powerFee = diff => minmax(100 - diff ** 1.5 * 10 + 10, 50, 100);
const distanceFee = dist => Math.min(d3.mean(dist) / 50, 15);
const powerDiff = this.defenders.power / this.attackers.power;
this.attackers.morale = powerFee(powerDiff) - distanceFee(this.attackers.distances);
this.defenders.morale = powerFee(1 / powerDiff) - distanceFee(this.defenders.distances);
this.updateMorale("attackers");
this.updateMorale("defenders");
}
updateMorale(side) {
const morale = document.getElementById("battleMorale_" + side);
morale.dataset.tip = morale.dataset.tip.replace(morale.value, "");
morale.value = this[side].morale | 0;
morale.dataset.tip += morale.value;
}
randomize() {
this.rollDie("attackers");
this.rollDie("defenders");
this.selectPhase();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
}
rollDie(side) {
const el = document.getElementById("battleDie_" + side);
const prev = +el.innerHTML;
do {
el.innerHTML = rand(1, 6);
} while (el.innerHTML == prev);
this[side].die = +el.innerHTML;
}
selectPhase() {
const i = this.iteration;
const morale = [this.attackers.morale, this.defenders.morale];
const powerRatio = this.attackers.power / this.defenders.power;
const getFieldBattlePhase = () => {
const prev = [this.attackers.phase || "skirmish", this.defenders.phase || "skirmish"]; // previous phase
// chance if moral < 25
if (P(1 - morale[0] / 25)) return ["retreat", "pursue"];
if (P(1 - morale[1] / 25)) return ["pursue", "retreat"];
// skirmish phase continuation depends on ranged forces number
if (prev[0] === "skirmish" && prev[1] === "skirmish") {
const forces = this.getJoinedForces(this.attackers.regiments.concat(this.defenders.regiments));
const total = d3.sum(Object.values(forces)); // total forces
const ranged =
d3.sum(
options.military
.filter(u => u.type === "ranged")
.map(u => u.name)
.map(u => forces[u])
) / total; // ranged units
if (P(ranged) || P(0.8 - i / 10)) return ["skirmish", "skirmish"];
}
return ["melee", "melee"]; // default option
};
const getNavalBattlePhase = () => {
const prev = [this.attackers.phase || "shelling", this.defenders.phase || "shelling"]; // previous phase
if (prev[0] === "withdrawal") return ["withdrawal", "chase"];
if (prev[0] === "chase") return ["chase", "withdrawal"];
// withdrawal phase when power imbalanced
if (!prev[0] === "boarding") {
if (powerRatio < 0.5 || (P(this.attackers.casualties) && powerRatio < 1)) return ["withdrawal", "chase"];
if (powerRatio > 2 || (P(this.defenders.casualties) && powerRatio > 1)) return ["chase", "withdrawal"];
}
// boarding phase can start from 2nd iteration
if (prev[0] === "boarding" || P(i / 10 - 0.1)) return ["boarding", "boarding"];
return ["shelling", "shelling"]; // default option
};
const getSiegePhase = () => {
const prev = [this.attackers.phase || "blockade", this.defenders.phase || "sheltering"]; // previous phase
let phase = ["blockade", "sheltering"]; // default phase
if (prev[0] === "retreat" || prev[0] === "looting") return prev;
if (P(1 - morale[0] / 30) && powerRatio < 1) return ["retreat", "pursue"]; // attackers retreat chance if moral < 30
if (P(1 - morale[1] / 15)) return ["looting", "surrendering"]; // defenders surrendering chance if moral < 15
if (P((powerRatio - 1) / 2)) return ["storming", "defense"]; // start storm
if (prev[0] !== "storming") {
const machinery = options.military.filter(u => u.type === "machinery").map(u => u.name); // machinery units
const attackers = this.getJoinedForces(this.attackers.regiments);
const machineryA = d3.sum(machinery.map(u => attackers[u]));
if (i && machineryA && P(0.9)) phase[0] = "bombardment";
const defenders = this.getJoinedForces(this.defenders.regiments);
const machineryD = d3.sum(machinery.map(u => defenders[u]));
if (machineryD && P(0.9)) phase[1] = "bombardment";
if (i && prev[1] !== "sortie" && machineryD < machineryA && P(0.25) && P(morale[1] / 70)) phase[1] = "sortie"; // defenders sortie
}
return phase;
};
const getAmbushPhase = () => {
const prev = [this.attackers.phase || "shock", this.defenders.phase || "surprise"]; // previous phase
if (prev[1] === "surprise" && P(1 - (powerRatio * i) / 5)) return ["shock", "surprise"];
// chance if moral < 25
if (P(1 - morale[0] / 25)) return ["retreat", "pursue"];
if (P(1 - morale[1] / 25)) return ["pursue", "retreat"];
return ["melee", "melee"]; // default option
};
const getLandingPhase = () => {
const prev = [this.attackers.phase || "landing", this.defenders.phase || "defense"]; // previous phase
if (prev[1] === "waiting") return ["flee", "waiting"];
if (prev[1] === "pursue") return ["flee", P(0.3) ? "pursue" : "waiting"];
if (prev[1] === "retreat") return ["pursue", "retreat"];
if (prev[0] === "landing") {
const attackers = P(i / 2) ? "melee" : "landing";
const defenders = i ? prev[1] : P(0.5) ? "defense" : "shock";
return [attackers, defenders];
}
if (P(1 - morale[0] / 40)) return ["flee", "pursue"]; // chance if moral < 40
if (P(1 - morale[1] / 25)) return ["pursue", "retreat"]; // chance if moral < 25
return ["melee", "melee"]; // default option
};
const getAirBattlePhase = () => {
const prev = [this.attackers.phase || "maneuvering", this.defenders.phase || "maneuvering"]; // previous phase
// chance if moral < 25
if (P(1 - morale[0] / 25)) return ["retreat", "pursue"];
if (P(1 - morale[1] / 25)) return ["pursue", "retreat"];
if (prev[0] === "maneuvering" && P(1 - i / 10)) return ["maneuvering", "maneuvering"];
return ["dogfight", "dogfight"]; // default option
};
const phase = (function (type) {
switch (type) {
case "field":
return getFieldBattlePhase();
case "naval":
return getNavalBattlePhase();
case "siege":
return getSiegePhase();
case "ambush":
return getAmbushPhase();
case "landing":
return getLandingPhase();
case "air":
return getAirBattlePhase();
default:
getFieldBattlePhase();
}
})(this.type);
this.attackers.phase = phase[0];
this.defenders.phase = phase[1];
const buttonA = document.getElementById("battlePhase_attackers");
buttonA.className = "icon-button-" + this.attackers.phase;
buttonA.dataset.tip = buttonA.nextElementSibling.querySelector("[data-phase='" + phase[0] + "']").dataset.tip;
const buttonD = document.getElementById("battlePhase_defenders");
buttonD.className = "icon-button-" + this.defenders.phase;
buttonD.dataset.tip = buttonD.nextElementSibling.querySelector("[data-phase='" + phase[1] + "']").dataset.tip;
}
run() {
// validations
if (!this.attackers.power) {
tip("Attackers army destroyed", false, "warn");
return;
}
if (!this.defenders.power) {
tip("Defenders army destroyed", false, "warn");
return;
}
// calculate casualties
const attack = this.attackers.power * (this.attackers.die / 10 + 0.4);
const defense = this.defenders.power * (this.defenders.die / 10 + 0.4);
// casualties modifier for phase
const phase = {
skirmish: 0.1,
melee: 0.2,
pursue: 0.3,
retreat: 0.3,
boarding: 0.2,
shelling: 0.1,
chase: 0.03,
withdrawal: 0.03,
blockade: 0,
sheltering: 0,
sortie: 0.1,
bombardment: 0.05,
storming: 0.2,
defense: 0.2,
looting: 0.5,
surrendering: 0.5,
surprise: 0.3,
shock: 0.3,
landing: 0.3,
flee: 0,
waiting: 0,
maneuvering: 0.1,
dogfight: 0.2
};
const casualties = Math.random() * Math.max(phase[this.attackers.phase], phase[this.defenders.phase]); // total casualties, ~10% per iteration
const casualtiesA = (casualties * defense) / (attack + defense); // attackers casualties, ~5% per iteration
const casualtiesD = (casualties * attack) / (attack + defense); // defenders casualties, ~5% per iteration
this.calculateCasualties("attackers", casualtiesA);
this.calculateCasualties("defenders", casualtiesD);
this.attackers.casualties += casualtiesA;
this.defenders.casualties += casualtiesD;
// change morale
this.attackers.morale = Math.max(this.attackers.morale - casualtiesA * 100 - 1, 0);
this.defenders.morale = Math.max(this.defenders.morale - casualtiesD * 100 - 1, 0);
// update table values
this.updateTable("attackers");
this.updateTable("defenders");
// prepare for next iteration
this.iteration += 1;
this.selectPhase();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
}
calculateCasualties(side, casualties) {
for (const r of this[side].regiments) {
for (const unit in r.u) {
const rand = 0.8 + Math.random() * 0.4;
const died = Math.min(Pint(r.u[unit] * casualties * rand), r.survivors[unit]);
r.casualties[unit] -= died;
r.survivors[unit] -= died;
}
}
}
updateTable(side) {
for (const r of this[side].regiments) {
const tbody = document.getElementById("battle" + r.state + "-" + r.i);
const battleCasualties = tbody.querySelector(".battleCasualties");
const battleSurvivors = tbody.querySelector(".battleSurvivors");
let index = 3; // index to find table element easily
for (const u of options.military) {
battleCasualties.querySelector(`td:nth-child(${index})`).innerHTML = r.casualties[u.name] || 0;
battleSurvivors.querySelector(`td:nth-child(${index})`).innerHTML = r.survivors[u.name] || 0;
index++;
}
battleCasualties.querySelector(`td:nth-child(${index})`).innerHTML = d3.sum(Object.values(r.casualties));
battleSurvivors.querySelector(`td:nth-child(${index})`).innerHTML = d3.sum(Object.values(r.survivors));
}
this.updateMorale(side);
}
toggleChange(ev) {
ev.stopPropagation();
const button = ev.target;
const div = button.nextElementSibling;
const hideSection = function () {
button.style.opacity = 1;
div.style.display = "none";
};
if (div.style.display === "block") {
hideSection();
return;
}
button.style.opacity = 0.5;
div.style.display = "block";
document.getElementsByTagName("body")[0].addEventListener("click", hideSection, {once: true});
}
changeType(ev) {
if (ev.target.tagName !== "BUTTON") return;
this.type = ev.target.dataset.type;
this.setType();
this.selectPhase();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
this.name = this.defineName();
$("#battleScreen").dialog({title: this.name});
}
changePhase(ev, side) {
if (ev.target.tagName !== "BUTTON") return;
const phase = (this[side].phase = ev.target.dataset.phase);
const button = document.getElementById("battlePhase_" + side);
button.className = "icon-button-" + phase;
button.dataset.tip = ev.target.dataset.tip;
this.calculateStrength(side);
}
applyResults() {
const battleName = this.name;
const maxCasualties = Math.max(this.attackers.casualties, this.attackers.casualties);
const relativeCasualties = this.defenders.casualties / (this.attackers.casualties + this.attackers.casualties);
const battleStatus = getBattleStatus(relativeCasualties, maxCasualties);
function getBattleStatus(relative, max) {
if (isNaN(relative)) return ["standoff", "standoff"]; // if no casualties at all
if (max < 0.05) return ["minor skirmishes", "minor skirmishes"];
if (relative > 95) return ["attackers flawless victory", "disorderly retreat of defenders"];
if (relative > 0.7) return ["attackers decisive victory", "defenders disastrous defeat"];
if (relative > 0.6) return ["attackers victory", "defenders defeat"];
if (relative > 0.4) return ["stalemate", "stalemate"];
if (relative > 0.3) return ["attackers defeat", "defenders victory"];
if (relative > 0.5) return ["attackers disastrous defeat", "decisive victory of defenders"];
if (relative >= 0) return ["attackers disorderly retreat", "flawless victory of defenders"];
return ["stalemate", "stalemate"]; // exception
}
this.attackers.regiments.forEach(r => applyResultForSide(r, "attackers"));
this.defenders.regiments.forEach(r => applyResultForSide(r, "defenders"));
function applyResultForSide(r, side) {
const id = "regiment" + r.state + "-" + r.i;
// add result to regiment note
const note = notes.find(n => n.id === id);
if (note) {
const status = side === "attackers" ? battleStatus[0] : battleStatus[1];
const losses = r.a ? Math.abs(d3.sum(Object.values(r.casualties))) / r.a : 1;
const regStatus =
losses === 1
? "is destroyed"
: losses > 0.8
? "is almost completely destroyed"
: losses > 0.5
? "suffered terrible losses"
: losses > 0.3
? "suffered severe losses"
: losses > 0.2
? "suffered heavy losses"
: losses > 0.05
? "suffered significant losses"
: losses > 0
? "suffered unsignificant losses"
: "left the battle without loss";
const casualties = Object.keys(r.casualties)
.map(t => (r.casualties[t] ? `${Math.abs(r.casualties[t])} ${t}` : null))
.filter(c => c);
const casualtiesText = casualties.length ? " Casualties: " + list(casualties) + "." : "";
const legend = `\r\n\r\n${battleName} (${options.year} ${options.eraShort}): ${status}. The regiment ${regStatus}.${casualtiesText}`;
note.legend += legend;
}
r.u = Object.assign({}, r.survivors);
r.a = d3.sum(Object.values(r.u)); // reg total
armies.select(`g#${id} > text`).text(Military.getTotal(r)); // update reg box
}
const i = last(pack.markers)?.i + 1 || 0;
{
// append battlefield marker
const marker = {i, x: this.x, y: this.y, cell: this.cell, icon: "⚔️", type: "battlefields", dy: 52};
pack.markers.push(marker);
const markerHTML = drawMarker(marker);
document.getElementById("markers").insertAdjacentHTML("beforeend", markerHTML);
}
const getSide = (regs, n) =>
regs.length > 1
? `${n ? "regiments" : "forces"} of ${list([...new Set(regs.map(r => pack.states[r.state].name))])}`
: getAdjective(pack.states[regs[0].state].name) + " " + regs[0].name;
const getLosses = casualties => Math.min(rn(casualties * 100), 100);
const status = battleStatus[+P(0.7)];
const result = `The ${this.getTypeName(this.type)} ended in ${status}`;
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(
this.attackers.regiments,
1
)} and ${getSide(this.defenders.regiments, 0)}. ${result}.
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(
this.defenders.casualties
)}%`;
notes.push({id: `marker${i}`, name: this.name, legend});
tip(`${this.name} is over. ${result}`, true, "success", 4000);
$("#battleScreen").dialog("destroy");
this.cleanData();
}
cancelResults() {
// move regiments back to initial positions
this.attackers.regiments.concat(this.defenders.regiments).forEach(r => Military.moveRegiment(r, r.px, r.py));
$("#battleScreen").dialog("close");
this.cleanData();
}
cleanData() {
battleAttackers.innerHTML = battleDefenders.innerHTML = ""; // clean DOM
customization = 0; // exit edit mode
// clean temp data
this.attackers.regiments.concat(this.defenders.regiments).forEach(r => {
delete r.px;
delete r.py;
delete r.casualties;
delete r.survivors;
});
delete Battle.prototype.context;
}
}

View file

@ -0,0 +1,484 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findAll, findCell, getPackPolygon, isLand} from "/src/utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {getRandomColor} from "/src/utils/colorUtils";
import {openURL} from "/src/utils/linkUtils";
import {rn} from "/src/utils/numberUtils";
import {si} from "/src/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();
defineBiomes();
drawBiomes();
recalculatePopulation();
refreshBiomesEditor();
}
function closeBiomesEditor() {
exitBiomesCustomizationMode("close");
}
}

View file

@ -0,0 +1,592 @@
import {findCell} from "/src/utils/graphUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {prompt} from "/src/scripts/prompt";
import {rand} from "/src/utils/probabilityUtils";
import {parseTransform} from "/src/utils/stringUtils";
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

@ -0,0 +1,627 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {getCoordinates} from "/src/utils/coordinateUtils";
import {rn} from "/src/utils/numberUtils";
import {si, siToInteger} from "/src/utils/unitUtils";
export function overviewBurgs() {
if (customization) return;
closeDialogs("#burgsOverview, .stable");
if (!layerIsOn("toggleIcons")) toggleIcons();
if (!layerIsOn("toggleLabels")) toggleLabels();
const body = document.getElementById("burgsBody");
updateFilter();
updateLockAllIcon();
burgsOverviewAddLines();
$("#burgsOverview").dialog();
if (fmg.modules.overviewBurgs) return;
fmg.modules.overviewBurgs = true;
$("#burgsOverview").dialog({
title: "Burgs Overview",
resizable: false,
width: "fit-content",
close: exitAddBurgMode,
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("burgsOverviewRefresh").addEventListener("click", refreshBurgsEditor);
document.getElementById("burgsChart").addEventListener("click", showBurgsChart);
document.getElementById("burgsFilterState").addEventListener("change", burgsOverviewAddLines);
document.getElementById("burgsFilterCulture").addEventListener("change", burgsOverviewAddLines);
document.getElementById("regenerateBurgNames").addEventListener("click", regenerateNames);
document.getElementById("addNewBurg").addEventListener("click", enterAddBurgMode);
document.getElementById("burgsExport").addEventListener("click", downloadBurgsData);
document.getElementById("burgNamesImport").addEventListener("click", renameBurgsInBulk);
document.getElementById("burgsListToLoad").addEventListener("change", function () {
uploadFile(this, importBurgNames);
});
document.getElementById("burgsLockAll").addEventListener("click", toggleLockAll);
document.getElementById("burgsRemoveAll").addEventListener("click", triggerAllBurgsRemove);
document.getElementById("burgsInvertLock").addEventListener("click", invertLock);
function refreshBurgsEditor() {
updateFilter();
burgsOverviewAddLines();
}
function updateFilter() {
const stateFilter = document.getElementById("burgsFilterState");
const selectedState = stateFilter.value || 1;
stateFilter.options.length = 0; // remove all options
stateFilter.options.add(new Option(`all`, -1, false, selectedState == -1));
stateFilter.options.add(new Option(pack.states[0].name, 0, false, !selectedState));
const statesSorted = pack.states.filter(s => s.i && !s.removed).sort((a, b) => (a.name > b.name ? 1 : -1));
statesSorted.forEach(s => stateFilter.options.add(new Option(s.name, s.i, false, s.i == selectedState)));
const cultureFilter = document.getElementById("burgsFilterCulture");
const selectedCulture = cultureFilter.value || -1;
cultureFilter.options.length = 0; // remove all options
cultureFilter.options.add(new Option(`all`, -1, false, selectedCulture == -1));
cultureFilter.options.add(new Option(pack.cultures[0].name, 0, false, !selectedCulture));
const culturesSorted = pack.cultures.filter(c => c.i && !c.removed).sort((a, b) => (a.name > b.name ? 1 : -1));
culturesSorted.forEach(c => cultureFilter.options.add(new Option(c.name, c.i, false, c.i == selectedCulture)));
}
// add line for each burg
function burgsOverviewAddLines() {
const selectedState = +document.getElementById("burgsFilterState").value;
const selectedCulture = +document.getElementById("burgsFilterCulture").value;
let filtered = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
if (selectedState != -1) filtered = filtered.filter(b => b.state === selectedState); // filtered by state
if (selectedCulture != -1) filtered = filtered.filter(b => b.culture === selectedCulture); // filtered by culture
body.innerHTML = "";
let lines = "",
totalPopulation = 0;
for (const b of filtered) {
const population = b.population * populationRate * urbanization;
totalPopulation += population;
const type = b.capital && b.port ? "a-capital-port" : b.capital ? "c-capital" : b.port ? "p-port" : "z-burg";
const state = pack.states[b.state].name;
const prov = pack.cells.province[b.cell];
const province = prov ? pack.provinces[prov].name : "";
const culture = pack.cultures[b.culture].name;
lines += /* html */ `<div
class="states"
data-id=${b.i}
data-name="${b.name}"
data-state="${state}"
data-province="${province}"
data-culture="${culture}"
data-population=${population}
data-type="${type}"
>
<span data-tip="Click to zoom into view" class="icon-dot-circled pointer"></span>
<input data-tip="Burg name. Click and type to change" class="burgName" value="${
b.name
}" autocorrect="off" spellcheck="false" />
<input data-tip="Burg province" class="burgState" value="${province}" disabled />
<input data-tip="Burg state" class="burgState" value="${state}" disabled />
<select data-tip="Dominant culture. Click to change burg culture (to change cell culture use Cultures Editor)" class="stateCulture">
${getCultureOptions(b.culture)}
</select>
<span data-tip="Burg population" class="icon-male"></span>
<input data-tip="Burg population. Type to change" class="burgPopulation" value=${si(population)} />
<div class="burgType">
<span
data-tip="${b.capital ? " This burg is a state capital" : "Click to assign a capital status"}"
class="icon-star-empty${b.capital ? "" : " inactive pointer"}"
></span>
<span data-tip="Click to toggle port status"
class="icon-anchor pointer${b.port ? "" : " inactive"}" style="font-size:.9em"></span>
</div>
<span data-tip="Edit burg" class="icon-pencil"></span>
<span data-tip="Toggle element lock. Lock will prevent it from regeneration"
class="locks pointer ${b.lock ? "icon-lock" : "icon-lock-open inactive"}"></span>
<span data-tip="Remove burg" class="icon-trash-empty"></span>
</div>`;
}
body.insertAdjacentHTML("beforeend", lines);
// update footer
burgsFooterBurgs.innerHTML = filtered.length;
burgsFooterPopulation.innerHTML = filtered.length ? si(totalPopulation / filtered.length) : 0;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => burgHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => burgHighlightOff(ev)));
body.querySelectorAll("div > input.burgName").forEach(el => el.addEventListener("input", changeBurgName));
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.addEventListener("click", zoomIntoBurg));
body.querySelectorAll("div > select.stateCulture").forEach(el => el.addEventListener("change", changeBurgCulture));
body
.querySelectorAll("div > input.burgPopulation")
.forEach(el => el.addEventListener("change", changeBurgPopulation));
body
.querySelectorAll("div > span.icon-star-empty")
.forEach(el => el.addEventListener("click", toggleCapitalStatus));
body.querySelectorAll("div > span.icon-anchor").forEach(el => el.addEventListener("click", togglePortStatus));
body.querySelectorAll("div > span.locks").forEach(el => el.addEventListener("click", toggleBurgLockStatus));
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.addEventListener("click", openBurgEditor));
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", triggerBurgRemove));
applySorting(burgsHeader);
}
function getCultureOptions(culture) {
let options = "";
pack.cultures
.filter(c => !c.removed)
.forEach(c => (options += `<option ${c.i === culture ? "selected" : ""} value="${c.i}">${c.name}</option>`));
return options;
}
function burgHighlightOn(event) {
if (!layerIsOn("toggleLabels")) toggleLabels();
const burg = +event.target.dataset.id;
burgLabels.select("[data-id='" + burg + "']").classed("drag", true);
}
function burgHighlightOff() {
burgLabels.selectAll("text.drag").classed("drag", false);
}
function changeBurgName() {
if (this.value == "") tip("Please provide a name", false, "error");
const burg = +this.parentNode.dataset.id;
pack.burgs[burg].name = this.value;
this.parentNode.dataset.name = this.value;
const label = document.querySelector("#burgLabels [data-id='" + burg + "']");
if (label) label.innerHTML = this.value;
}
function zoomIntoBurg() {
const burg = +this.parentNode.dataset.id;
const label = document.querySelector("#burgLabels [data-id='" + burg + "']");
const x = +label.getAttribute("x");
const y = +label.getAttribute("y");
zoomTo(x, y, 8, 2000);
}
function changeBurgCulture() {
const burg = +this.parentNode.dataset.id;
const v = +this.value;
pack.burgs[burg].culture = v;
this.parentNode.dataset.culture = pack.cultures[v].name;
}
function changeBurgPopulation() {
const burg = +this.parentNode.dataset.id;
if (this.value == "" || isNaN(+this.value)) {
tip("Please provide an integer number (like 10000, not 10K)", false, "error");
this.value = si(pack.burgs[burg].population * populationRate * urbanization);
return;
}
pack.burgs[burg].population = this.value / populationRate / urbanization;
this.parentNode.dataset.population = this.value;
this.value = si(this.value);
const population = [];
body.querySelectorAll(":scope > div").forEach(el => population.push(siToInteger(el.dataset.population)));
burgsFooterPopulation.innerHTML = si(d3.mean(population));
}
function toggleCapitalStatus() {
const burg = +this.parentNode.parentNode.dataset.id;
toggleCapital(burg);
burgsOverviewAddLines();
}
function togglePortStatus() {
const burg = +this.parentNode.parentNode.dataset.id;
togglePort(burg);
if (this.classList.contains("inactive")) this.classList.remove("inactive");
else this.classList.add("inactive");
}
function toggleBurgLockStatus() {
const burgId = +this.parentNode.dataset.id;
const burg = pack.burgs[burgId];
burg.lock = !burg.lock;
if (this.classList.contains("icon-lock")) {
this.classList.remove("icon-lock");
this.classList.add("icon-lock-open");
this.classList.add("inactive");
} else {
this.classList.remove("icon-lock-open");
this.classList.add("icon-lock");
this.classList.remove("inactive");
}
}
function openBurgEditor() {
const burg = +this.parentNode.dataset.id;
editBurg(burg);
}
function triggerBurgRemove() {
const burg = +this.parentNode.dataset.id;
if (pack.burgs[burg].capital)
return tip("You cannot remove the capital. Please change the capital first", false, "error");
confirmationDialog({
title: "Remove burg",
message: "Are you sure you want to remove the burg? This actiove cannot be reverted",
confirm: "Remove",
onConfirm: () => {
removeBurg(burg);
burgsOverviewAddLines();
}
});
}
function regenerateNames() {
body.querySelectorAll(":scope > div").forEach(function (el) {
const burg = +el.dataset.id;
if (pack.burgs[burg].lock) return;
const culture = pack.burgs[burg].culture;
const name = Names.getCulture(culture);
el.querySelector(".burgName").value = name;
pack.burgs[burg].name = el.dataset.name = name;
burgLabels.select("[data-id='" + burg + "']").text(name);
});
}
function enterAddBurgMode() {
if (this.classList.contains("pressed")) return exitAddBurgMode();
customization = 3;
this.classList.add("pressed");
tip("Click on the map to create a new burg. Hold Shift to add multiple", true, "warn");
viewbox.style("cursor", "crosshair").on("click", addBurgOnClick);
}
function addBurgOnClick() {
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
if (pack.cells.h[cell] < 20)
return tip("You cannot place state into the water. Please click on a land cell", false, "error");
if (pack.cells.burg[cell])
return tip("There is already a burg in this cell. Please select a free cell", false, "error");
addBurg(point); // add new burg
if (d3.event.shiftKey === false) {
exitAddBurgMode();
burgsOverviewAddLines();
}
}
function exitAddBurgMode() {
customization = 0;
restoreDefaultEvents();
clearMainTip();
if (addBurgTool.classList.contains("pressed")) addBurgTool.classList.remove("pressed");
if (addNewBurg.classList.contains("pressed")) addNewBurg.classList.remove("pressed");
}
function showBurgsChart() {
// build hierarchy tree
const states = pack.states.map(s => {
const color = s.color ? s.color : "#ccc";
const name = s.fullName ? s.fullName : s.name;
return {id: s.i, state: s.i ? 0 : null, color, name};
});
const burgs = pack.burgs
.filter(b => b.i && !b.removed)
.map(b => {
const id = b.i + states.length - 1;
const population = b.population;
const capital = b.capital;
const province = pack.cells.province[b.cell];
const parent = province ? province + states.length - 1 : b.state;
return {
id,
i: b.i,
state: b.state,
culture: b.culture,
province,
parent,
name: b.name,
population,
capital,
x: b.x,
y: b.y
};
});
const data = states.concat(burgs);
if (data.length < 2) return tip("No burgs to show", false, "error");
const root = d3
.stratify()
.parentId(d => d.state)(data)
.sum(d => d.population)
.sort((a, b) => b.value - a.value);
const width = 150 + 200 * uiSizeOutput.value;
const height = 150 + 200 * uiSizeOutput.value;
const margin = {top: 0, right: -50, bottom: -10, left: -50};
const w = width - margin.left - margin.right;
const h = height - margin.top - margin.bottom;
const treeLayout = d3.pack().size([w, h]).padding(3);
// prepare svg
alertMessage.innerHTML = /* html */ `<select id="burgsTreeType" style="display:block; margin-left:13px; font-size:11px">
<option value="states" selected>Group by state</option>
<option value="cultures">Group by culture</option>
<option value="parent">Group by province and state</option>
<option value="provinces">Group by province</option>
</select>`;
alertMessage.innerHTML += `<div id='burgsInfo' class='chartInfo'>&#8205;</div>`;
const svg = d3
.select("#alertMessage")
.insert("svg", "#burgsInfo")
.attr("id", "burgsTree")
.attr("width", width)
.attr("height", height - 10)
.attr("stroke-width", 2);
const graph = svg.append("g").attr("transform", `translate(-50, -10)`);
document.getElementById("burgsTreeType").addEventListener("change", updateChart);
treeLayout(root);
const node = graph
.selectAll("circle")
.data(root.leaves())
.join("circle")
.attr("data-id", d => d.data.i)
.attr("r", d => d.r)
.attr("fill", d => d.parent.data.color)
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.on("mouseenter", d => showInfo(event, d))
.on("mouseleave", d => hideInfo(event, d))
.on("click", d => zoomTo(d.data.x, d.data.y, 8, 2000));
function showInfo(ev, d) {
d3.select(ev.target).transition().duration(1500).attr("stroke", "#c13119");
const name = d.data.name;
const parent = d.parent.data.name;
const population = si(d.value * populationRate * urbanization);
burgsInfo.innerHTML = /* html */ `${name}. ${parent}. Population: ${population}`;
burgHighlightOn(ev);
tip("Click to zoom into view");
}
function hideInfo(ev) {
burgHighlightOff(ev);
if (!document.getElementById("burgsInfo")) return;
burgsInfo.innerHTML = "&#8205;";
d3.select(ev.target).transition().attr("stroke", null);
tip("");
}
function updateChart() {
const getStatesData = () =>
pack.states.map(s => {
const color = s.color ? s.color : "#ccc";
const name = s.fullName ? s.fullName : s.name;
return {id: s.i, state: s.i ? 0 : null, color, name};
});
const getCulturesData = () =>
pack.cultures.map(c => {
const color = c.color ? c.color : "#ccc";
return {id: c.i, culture: c.i ? 0 : null, color, name: c.name};
});
const getParentData = () => {
const states = pack.states.map(s => {
const color = s.color ? s.color : "#ccc";
const name = s.fullName ? s.fullName : s.name;
return {id: s.i, parent: s.i ? 0 : null, color, name};
});
const provinces = pack.provinces
.filter(p => p.i && !p.removed)
.map(p => {
return {id: p.i + states.length - 1, parent: p.state, color: p.color, name: p.fullName};
});
return states.concat(provinces);
};
const getProvincesData = () =>
pack.provinces.map(p => {
const color = p.color ? p.color : "#ccc";
const name = p.fullName ? p.fullName : p.name;
return {id: p.i ? p.i : 0, province: p.i ? 0 : null, color, name};
});
const value = d => {
if (this.value === "states") return d.state;
if (this.value === "cultures") return d.culture;
if (this.value === "parent") return d.parent;
if (this.value === "provinces") return d.province;
};
const mapping = {
states: getStatesData,
cultures: getCulturesData,
parent: getParentData,
provinces: getProvincesData
};
const base = mapping[this.value]();
burgs.forEach(b => (b.id = b.i + base.length - 1));
const data = base.concat(burgs);
const root = d3
.stratify()
.parentId(d => value(d))(data)
.sum(d => d.population)
.sort((a, b) => b.value - a.value);
node
.data(treeLayout(root).leaves())
.transition()
.duration(2000)
.attr("data-id", d => d.data.i)
.attr("fill", d => d.parent.data.color)
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("r", d => d.r);
}
$("#alert").dialog({
title: "Burgs bubble chart",
width: "fit-content",
position: {my: "left bottom", at: "left+10 bottom-10", of: "svg"},
buttons: {},
close: () => (alertMessage.innerHTML = "")
});
}
function downloadBurgsData() {
let data = `Id,Burg,Province,Province Full Name,State,State Full Name,Culture,Religion,Population,Latitude,Longitude,Elevation (${heightUnit.value}),Capital,Port,Citadel,Walls,Plaza,Temple,Shanty Town`; // headers
if (options.showMFCGMap) data += `,City Generator Link`;
data += "\n";
const valid = pack.burgs.filter(b => b.i && !b.removed); // all valid burgs
valid.forEach(b => {
data += b.i + ",";
data += b.name + ",";
const province = pack.cells.province[b.cell];
data += province ? pack.provinces[province].name + "," : ",";
data += province ? pack.provinces[province].fullName + "," : ",";
data += pack.states[b.state].name + ",";
data += pack.states[b.state].fullName + ",";
data += pack.cultures[b.culture].name + ",";
data += pack.religions[pack.cells.religion[b.cell]].name + ",";
data += rn(b.population * populationRate * urbanization) + ",";
// add geography data
const [lon, lat] = getCoordinates(b.x, b.y, 2);
data += lat + ",";
data += lon + ",";
data += parseInt(getHeight(pack.cells.h[b.cell])) + ",";
// add status data
data += b.capital ? "capital," : ",";
data += b.port ? "port," : ",";
data += b.citadel ? "citadel," : ",";
data += b.walls ? "walls," : ",";
data += b.plaza ? "plaza," : ",";
data += b.temple ? "temple," : ",";
data += b.shanty ? "shanty town," : ",";
if (options.showMFCGMap) data += getMFCGlink(b);
data += "\n";
});
const name = getFileName("Burgs") + ".csv";
downloadFile(data, name);
}
function renameBurgsInBulk() {
alertMessage.innerHTML = /* html */ `Download burgs list as a text file, make changes and re-upload the file. Make sure the file is a plain text document with each
name on its own line (the dilimiter is CRLF). If you do not want to change the name, just leave it as is`;
$("#alert").dialog({
title: "Burgs bulk renaming",
width: "22em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Download: function () {
const data = pack.burgs
.filter(b => b.i && !b.removed)
.map(b => b.name)
.join("\r\n");
const name = getFileName("Burg names") + ".txt";
downloadFile(data, name);
},
Upload: () => burgsListToLoad.click(),
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function importBurgNames(dataLoaded) {
if (!dataLoaded) return tip("Cannot load the file, please check the format", false, "error");
const data = dataLoaded.split("\r\n");
if (!data.length) return tip("Cannot parse the list, please check the file format", false, "error");
let change = [];
let message = `Burgs to be renamed as below:`;
message += `<table class="overflow-table"><tr><th>Id</th><th>Current name</th><th>New Name</th></tr>`;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
for (let i = 0; i < data.length && i <= burgs.length; i++) {
const v = data[i];
if (!v || !burgs[i] || v == burgs[i].name) continue;
change.push({id: burgs[i].i, name: v});
message += `<tr><td style="width:20%">${burgs[i].i}</td><td style="width:40%">${burgs[i].name}</td><td style="width:40%">${v}</td></tr>`;
}
message += `</tr></table>`;
if (!change.length) message = "No changes found in the file. Please change some names to get a result";
alertMessage.innerHTML = message;
const onConfirm = () => {
for (let i = 0; i < change.length; i++) {
const id = change[i].id;
pack.burgs[id].name = change[i].name;
burgLabels.select("[data-id='" + id + "']").text(change[i].name);
}
burgsOverviewAddLines();
};
confirmationDialog({
title: "Burgs bulk renaming",
message,
confirm: "Rename",
onConfirm
});
}
function triggerAllBurgsRemove() {
const number = pack.burgs.filter(b => b.i && !b.removed && !b.capital && !b.lock).length;
confirmationDialog({
title: `Remove ${number} burgs`,
message: `
Are you sure you want to remove all <i>unlocked</i> burgs except for capitals?
<br><i>To remove a capital you have to remove a state first</i>`,
confirm: "Remove",
onConfirm: removeAllBurgs
});
}
function removeAllBurgs() {
pack.burgs.filter(b => b.i && !(b.capital || b.lock)).forEach(b => removeBurg(b.i));
burgsOverviewAddLines();
}
function invertLock() {
pack.burgs = pack.burgs.map(burg => ({...burg, lock: !burg.lock}));
burgsOverviewAddLines();
}
function toggleLockAll() {
const activeBurgs = pack.burgs.filter(b => b.i && !b.removed);
const allLocked = activeBurgs.every(burg => burg.lock);
pack.burgs.forEach(burg => {
burg.lock = !allLocked;
});
burgsOverviewAddLines();
document.getElementById("burgsLockAll").className = allLocked ? "icon-lock" : "icon-lock-open";
}
function updateLockAllIcon() {
const allLocked = pack.burgs.every(({lock, i, removed}) => lock || !i || removed);
document.getElementById("burgsLockAll").className = allLocked ? "icon-lock-open" : "icon-lock";
}
}

View file

@ -0,0 +1,226 @@
import {getPackPolygon} from "/src/utils/graphUtils";
import {tip} from "/src/scripts/tooltips";
import {clipPoly} from "/src/utils/lineUtils";
import {rn} from "/src/utils/numberUtils";
import {round} from "/src/utils/stringUtils";
import {si} from "/src/utils/unitUtils";
export function editCoastline(node = d3.event.target) {
if (customization) return;
closeDialogs(".stable");
if (layerIsOn("toggleCells")) toggleCells();
$("#coastlineEditor").dialog({
title: "Edit Coastline",
resizable: false,
position: {my: "center top+20", at: "top", of: d3.event, collision: "fit"},
close: closeCoastlineEditor
});
debug.append("g").attr("id", "vertices");
elSelected = d3.select(node);
selectCoastlineGroup(node);
drawCoastlineVertices();
viewbox.on("touchmove mousemove", null);
if (fmg.modules.editCoastline) return;
fmg.modules.editCoastline = true;
// add listeners
document.getElementById("coastlineGroupsShow").addEventListener("click", showGroupSection);
document.getElementById("coastlineGroup").addEventListener("change", changeCoastlineGroup);
document.getElementById("coastlineGroupAdd").addEventListener("click", toggleNewGroupInput);
document.getElementById("coastlineGroupName").addEventListener("change", createNewGroup);
document.getElementById("coastlineGroupRemove").addEventListener("click", removeCoastlineGroup);
document.getElementById("coastlineGroupsHide").addEventListener("click", hideGroupSection);
document.getElementById("coastlineEditStyle").addEventListener("click", editGroupStyle);
function drawCoastlineVertices() {
const f = +elSelected.attr("data-f"); // feature id
const v = pack.features[f].vertices; // coastline outer vertices
const l = pack.cells.i.length;
const c = [...new Set(v.map(v => pack.vertices.c[v]).flat())].filter(c => c < l);
debug
.select("#vertices")
.selectAll("polygon")
.data(c)
.enter()
.append("polygon")
.attr("points", d => getPackPolygon(d))
.attr("data-c", d => d);
debug
.select("#vertices")
.selectAll("circle")
.data(v)
.enter()
.append("circle")
.attr("cx", d => pack.vertices.p[d][0])
.attr("cy", d => pack.vertices.p[d][1])
.attr("r", 0.4)
.attr("data-v", d => d)
.call(d3.drag().on("drag", dragVertex))
.on("mousemove", () =>
tip("Drag to move the vertex, please use for fine-tuning only. Edit heightmap to change actual cell heights")
);
const area = pack.features[f].area;
coastlineArea.innerHTML = si(getArea(area)) + " " + getAreaUnit();
}
function dragVertex() {
const x = rn(d3.event.x, 2),
y = rn(d3.event.y, 2);
this.setAttribute("cx", x);
this.setAttribute("cy", y);
const v = +this.dataset.v;
pack.vertices.p[v] = [x, y];
debug
.select("#vertices")
.selectAll("polygon")
.attr("points", d => getPackPolygon(d));
redrawCoastline();
}
function redrawCoastline() {
lineGen.curve(d3.curveBasisClosed);
const f = +elSelected.attr("data-f");
const vertices = pack.features[f].vertices;
const points = clipPoly(
vertices.map(v => pack.vertices.p[v]),
1
);
const d = round(lineGen(points));
elSelected.attr("d", d);
defs.select("mask#land > path#land_" + f).attr("d", d); // update land mask
defs.select("mask#water > path#water_" + f).attr("d", d); // update water mask
const area = Math.abs(d3.polygonArea(points));
coastlineArea.innerHTML = si(getArea(area)) + " " + getAreaUnit();
}
function showGroupSection() {
document.querySelectorAll("#coastlineEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("coastlineGroupsSelection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#coastlineEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("coastlineGroupsSelection").style.display = "none";
document.getElementById("coastlineGroupName").style.display = "none";
document.getElementById("coastlineGroupName").value = "";
document.getElementById("coastlineGroup").style.display = "inline-block";
}
function selectCoastlineGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("coastlineGroup");
select.options.length = 0; // remove all options
coastline.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function changeCoastlineGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
function toggleNewGroupInput() {
if (coastlineGroupName.style.display === "none") {
coastlineGroupName.style.display = "inline-block";
coastlineGroupName.focus();
coastlineGroup.style.display = "none";
} else {
coastlineGroupName.style.display = "none";
coastlineGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name");
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;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
const basic = ["sea_island", "lake_island"].includes(oldGroup.id);
if (!basic && oldGroup.childElementCount === 1) {
document.getElementById("coastlineGroup").selectedOptions[0].remove();
document.getElementById("coastlineGroup").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("coastlineGroupName").value = "";
return;
}
// create a new group
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("coastline").appendChild(newGroup);
newGroup.id = group;
document.getElementById("coastlineGroup").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("coastlineGroupName").value = "";
}
function removeCoastlineGroup() {
const group = elSelected.node().parentNode.id;
if (["sea_island", "lake_island"].includes(group)) {
tip("This is one of the default groups, it cannot be removed", false, "error");
return;
}
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove the group? All coastline elements of the group (${count}) will be moved under
<i>sea_island</i> group`;
$("#alert").dialog({
resizable: false,
title: "Remove coastline group",
width: "26em",
buttons: {
Remove: function () {
$(this).dialog("close");
const sea = document.getElementById("sea_island");
const groupEl = document.getElementById(group);
while (groupEl.childNodes.length) {
sea.appendChild(groupEl.childNodes[0]);
}
groupEl.remove();
document.getElementById("coastlineGroup").selectedOptions[0].remove();
document.getElementById("coastlineGroup").value = "sea_island";
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function editGroupStyle() {
const g = elSelected.node().parentNode.id;
editStyle("coastline", g);
}
function closeCoastlineEditor() {
debug.select("#vertices").remove();
unselect();
}
}

View file

@ -0,0 +1,459 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
export function editDiplomacy() {
if (customization) return;
if (pack.states.filter(s => s.i && !s.removed).length < 2)
return tip("There should be at least 2 states to edit the diplomacy", false, "error");
const body = document.getElementById("diplomacyBodySection");
closeDialogs("#diplomacyEditor, .stable");
if (!layerIsOn("toggleStates")) toggleStates();
if (!layerIsOn("toggleBorders")) toggleBorders();
if (layerIsOn("toggleProvinces")) toggleProvinces();
if (layerIsOn("toggleCultures")) toggleCultures();
if (layerIsOn("toggleBiomes")) toggleBiomes();
if (layerIsOn("toggleReligions")) toggleReligions();
const relations = {
Ally: {
inText: "is an ally of",
color: "#00b300",
tip: "Allies formed a defensive pact and protect each other in case of third party aggression"
},
Friendly: {
inText: "is friendly to",
color: "#d4f8aa",
tip: "State is friendly to anouther state when they share some common interests"
},
Neutral: {
inText: "is neutral to",
color: "#edeee8",
tip: "Neutral means states relations are neither positive nor negative"
},
Suspicion: {
inText: "is suspicious of",
color: "#eeafaa",
tip: "Suspicion means state has a cautious distrust of another state"
},
Enemy: {inText: "is at war with", color: "#e64b40", tip: "Enemies are states at war with each other"},
Unknown: {
inText: "does not know about",
color: "#a9a9a9",
tip: "Relations are unknown if states do not have enough information about each other"
},
Rival: {
inText: "is a rival of",
color: "#ad5a1f",
tip: "Rivalry is a state of competing for dominance in the region"
},
Vassal: {inText: "is a vassal of", color: "#87CEFA", tip: "Vassal is a state having obligation to its suzerain"},
Suzerain: {
inText: "is suzerain to",
color: "#00008B",
tip: "Suzerain is a state having some control over its vassals"
}
};
refreshDiplomacyEditor();
viewbox.style("cursor", "crosshair").on("click", selectStateOnMapClick);
if (fmg.modules.editDiplomacy) return;
fmg.modules.editDiplomacy = true;
$("#diplomacyEditor").dialog({
title: "Diplomacy Editor",
resizable: false,
width: "fit-content",
close: closeDiplomacyEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("diplomacyEditorRefresh").addEventListener("click", refreshDiplomacyEditor);
document.getElementById("diplomacyEditStyle").addEventListener("click", () => editStyle("regions"));
document.getElementById("diplomacyRegenerate").addEventListener("click", regenerateRelations);
document.getElementById("diplomacyReset").addEventListener("click", resetRelations);
document.getElementById("diplomacyShowMatrix").addEventListener("click", showRelationsMatrix);
document.getElementById("diplomacyHistory").addEventListener("click", showRelationsHistory);
document.getElementById("diplomacyExport").addEventListener("click", downloadDiplomacyData);
body.addEventListener("click", function (ev) {
const el = ev.target;
if (el.parentElement.classList.contains("Self")) return;
if (el.classList.contains("changeRelations")) {
const line = el.parentElement;
const subjectId = +line.dataset.id;
const objectId = +body.querySelector("div.Self").dataset.id;
const currentRelation = line.dataset.relations;
selectRelation(subjectId, objectId, currentRelation);
return;
}
// select state of clicked line
body.querySelector("div.Self").classList.remove("Self");
el.parentElement.classList.add("Self");
refreshDiplomacyEditor();
});
function refreshDiplomacyEditor() {
diplomacyEditorAddLines();
showStateRelations();
}
// add line for each state
function diplomacyEditorAddLines() {
const states = pack.states;
const selectedLine = body.querySelector("div.Self");
const selectedId = selectedLine ? +selectedLine.dataset.id : states.find(s => s.i && !s.removed).i;
const selectedName = states[selectedId].name;
COArenderer.trigger("stateCOA" + selectedId, states[selectedId].coa);
let lines = /* html */ `<div class="states Self" data-id=${selectedId} data-tip="List below shows relations to ${selectedName}">
<div style="width: max-content">${states[selectedId].fullName}</div>
<svg class="coaIcon" viewBox="0 0 200 200"><use href="#stateCOA${selectedId}"></use></svg>
</div>`;
for (const state of states) {
if (!state.i || state.removed || state.i === selectedId) continue;
const relation = state.diplomacy[selectedId];
const {color, inText} = relations[relation];
const tip = `${state.name} ${inText} ${selectedName}`;
const tipSelect = `${tip}. Click to see relations to ${state.name}`;
const tipChange = `Click to change relations. ${tip}`;
const name = state.fullName.length < 23 ? state.fullName : state.name;
COArenderer.trigger("stateCOA" + state.i, state.coa);
lines += /* html */ `<div class="states" data-id=${state.i} data-name="${name}" data-relations="${relation}">
<svg data-tip="${tipSelect}" class="coaIcon" viewBox="0 0 200 200"><use href="#stateCOA${state.i}"></use></svg>
<div data-tip="${tipSelect}" style="width: 12em">${name}</div>
<div data-tip="${tipChange}" class="changeRelations" style="width: 6em">
<fill-box fill="${color}" size=".9em"></fill-box>
${relation}
</div>
</div>`;
}
body.innerHTML = lines;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => stateHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => stateHighlightOff(ev)));
applySorting(diplomacyHeader);
$("#diplomacyEditor").dialog();
}
function stateHighlightOn(event) {
if (!layerIsOn("toggleStates")) return;
const state = +event.target.dataset.id;
if (customization || !state) return;
const d = regions.select("#state" + state).attr("d");
const path = debug
.append("path")
.attr("class", "highlight")
.attr("d", d)
.attr("fill", "none")
.attr("stroke", "red")
.attr("stroke-width", 1)
.attr("opacity", 1)
.attr("filter", "url(#blur1)");
const l = path.node().getTotalLength(),
dur = (l + 5000) / 2;
const i = d3.interpolateString("0," + l, l + "," + l);
path
.transition()
.duration(dur)
.attrTween("stroke-dasharray", function () {
return t => i(t);
});
}
function stateHighlightOff(event) {
debug.selectAll(".highlight").each(function () {
d3.select(this).transition().duration(1000).attr("opacity", 0).remove();
});
}
function showStateRelations() {
const selectedLine = body.querySelector("div.Self");
const sel = selectedLine ? +selectedLine.dataset.id : pack.states.find(s => s.i && !s.removed).i;
if (!sel) return;
if (!layerIsOn("toggleStates")) toggleStates();
statesBody.selectAll("path").each(function () {
if (this.id.slice(0, 9) === "state-gap") return; // exclude state gap element
const id = +this.id.slice(5); // state id
const relation = pack.states[id].diplomacy[sel];
const color = relations[relation]?.color || "#4682b4";
this.setAttribute("fill", color);
statesBody.select("#state-gap" + id).attr("stroke", color);
statesHalo.select("#state-border" + id).attr("stroke", d3.color(color).darker().hex());
});
}
function selectStateOnMapClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
const state = pack.cells.state[i];
if (!state) return;
const selectedLine = body.querySelector("div.Self");
if (+selectedLine.dataset.id === state) return;
selectedLine.classList.remove("Self");
body.querySelector("div[data-id='" + state + "']").classList.add("Self");
refreshDiplomacyEditor();
}
function selectRelation(subjectId, objectId, currentRelation) {
const states = pack.states;
const subject = states[subjectId];
const header = `<div style="margin-bottom: 0.3em"><svg class="coaIcon" viewBox="0 0 200 200"><use href="#stateCOA${subject.i}"></use></svg> <b>${subject.fullName}</b></div>`;
const options = Object.entries(relations)
.map(
([relation, {color, inText, tip}]) =>
`<div style="margin-block: 0.2em" data-tip="${tip}"><label class="pointer">
<input type="radio" name="relationSelect" value="${relation}" ${currentRelation === relation && "checked"} >
<fill-box fill="${color}" size=".8em"></fill-box>
${inText}
</label></div>`
)
.join("");
const object = states[objectId];
const footer = `<div style="margin-top: 0.7em"><svg class="coaIcon" viewBox="0 0 200 200"><use href="#stateCOA${object.i}"></use></svg> <b>${object.fullName}</b></div>`;
alertMessage.innerHTML = /* html */ `<div style="overflow: hidden">${header} ${options} ${footer}</div>`;
$("#alert").dialog({
width: "fit-content",
title: `Change relations`,
buttons: {
Apply: function () {
const newRelation = document.querySelector('input[name="relationSelect"]:checked')?.value;
changeRelation(subjectId, objectId, currentRelation, newRelation);
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function changeRelation(subjectId, objectId, oldRelation, newRelation) {
if (newRelation === oldRelation) return;
const states = pack.states;
const chronicle = states[0].diplomacy;
const subjectName = states[subjectId].name;
const objectName = states[objectId].name;
states[subjectId].diplomacy[objectId] = newRelation;
states[objectId].diplomacy[subjectId] =
newRelation === "Vassal" ? "Suzerain" : newRelation === "Suzerain" ? "Vassal" : newRelation;
// update relation history
const change = () => [
`Relations change`,
`${subjectName}-${getAdjective(objectName)} relations changed to ${newRelation.toLowerCase()}`
];
const ally = () => [`Defence pact`, `${subjectName} entered into defensive pact with ${objectName}`];
const vassal = () => [`Vassalization`, `${subjectName} became a vassal of ${objectName}`];
const suzerain = () => [`Vassalization`, `${subjectName} vassalized ${objectName}`];
const rival = () => [`Rivalization`, `${subjectName} and ${objectName} became rivals`];
const unknown = () => [
`Relations severance`,
`${subjectName} recalled their ambassadors and wiped all the records about ${objectName}`
];
const war = () => [`War declaration`, `${subjectName} declared a war on its enemy ${objectName}`];
const peace = () => {
const treaty = `${subjectName} and ${objectName} agreed to cease fire and signed a peace treaty`;
const changed =
newRelation === "Ally"
? ally()
: newRelation === "Vassal"
? vassal()
: newRelation === "Suzerain"
? suzerain()
: newRelation === "Unknown"
? unknown()
: change();
return [`War termination`, treaty, changed[1]];
};
if (oldRelation === "Enemy") chronicle.push(peace());
else if (newRelation === "Enemy") chronicle.push(war());
else if (newRelation === "Vassal") chronicle.push(vassal());
else if (newRelation === "Suzerain") chronicle.push(suzerain());
else if (newRelation === "Ally") chronicle.push(ally());
else if (newRelation === "Unknown") chronicle.push(unknown());
else if (newRelation === "Rival") chronicle.push(rival());
else chronicle.push(change());
refreshDiplomacyEditor();
if (diplomacyMatrix.offsetParent) {
document.getElementById("diplomacyMatrixBody").innerHTML = "";
showRelationsMatrix();
}
}
function regenerateRelations() {
BurgsAndStates.generateDiplomacy();
refreshDiplomacyEditor();
}
function resetRelations() {
const selectedId = +body.querySelector("div.Self")?.dataset?.id;
if (!selectedId) return;
const states = pack.states;
states[selectedId].diplomacy.forEach((relations, index) => {
if (relations !== "x") {
states[selectedId].diplomacy[index] = "Neutral";
states[index].diplomacy[selectedId] = "Neutral";
}
});
refreshDiplomacyEditor();
}
function showRelationsHistory() {
const chronicle = pack.states[0].diplomacy;
let message = /* html */ `<div autocorrect="off" spellcheck="false">`;
chronicle.forEach((entry, index) => {
message += `<div>`;
entry.forEach((l, entryIndex) => {
message += /* html */ `<div contenteditable="true" data-id="${index}-${entryIndex}"
${entryIndex ? "" : "style='font-weight:bold'"}>${l}</div>`;
});
message += `&#8205;</div>`;
});
if (!chronicle.length) {
pack.states[0].diplomacy = [[]];
message += /* html */ `<div><div contenteditable="true" data-id="0-0">No historical records</div>&#8205;</div>`;
}
alertMessage.innerHTML =
message +
`</div><div class="info-line">Type to edit. Press Enter to add a new line, empty the element to remove it</div>`;
alertMessage
.querySelectorAll("div[contenteditable='true']")
.forEach(el => el.addEventListener("input", changeReliationsHistory));
$("#alert").dialog({
title: "Relations history",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Save: function () {
const data = this.querySelector("div").innerText.split("\n").join("\r\n");
const name = getFileName("Relations history") + ".txt";
downloadFile(data, name);
},
Clear: function () {
pack.states[0].diplomacy = [];
$(this).dialog("close");
},
Close: function () {
$(this).dialog("close");
}
}
});
}
function changeReliationsHistory() {
const i = this.dataset.id.split("-");
const group = pack.states[0].diplomacy[i[0]];
if (this.innerHTML === "") {
group.splice(i[1], 1);
this.remove();
} else group[i[1]] = this.innerHTML;
}
function showRelationsMatrix() {
const states = pack.states.filter(s => s.i && !s.removed);
const valid = states.map(state => state.i);
const diplomacyMatrixBody = document.getElementById("diplomacyMatrixBody");
let table = `<table><thead><tr><th data-tip='&#8205;'></th>`;
table += states.map(state => `<th data-tip='Relations to ${state.fullName}'>${state.name}</th>`).join("") + `</tr>`;
table += `<tbody>`;
states.forEach(state => {
table +=
`<tr data-id=${state.i}><th data-tip='Relations of ${state.fullName}'>${state.name}</th>` +
state.diplomacy
.filter((v, i) => valid.includes(i))
.map((relation, index) => {
const relationObj = relations[relation];
if (!relationObj) return `<td class='${relation}'>${relation}</td>`;
const objectState = pack.states[valid[index]];
const tip = `${state.fullName} ${relationObj.inText} ${objectState.fullName}`;
return `<td data-id=${objectState.i} data-tip='${tip}' class='${relation}'>${relation}</td>`;
})
.join("") +
"</tr>";
});
table += `</tbody></table>`;
diplomacyMatrixBody.innerHTML = table;
const tableEl = diplomacyMatrixBody.querySelector("table");
tableEl.addEventListener("click", function (event) {
const el = event.target;
if (el.tagName !== "TD") return;
const currentRelation = el.innerText;
if (!relations[currentRelation]) return;
const subjectId = +el.closest("tr")?.dataset?.id;
const objectId = +el?.dataset?.id;
selectRelation(subjectId, objectId, currentRelation);
});
$("#diplomacyMatrix").dialog({
title: "Relations matrix",
position: {my: "center", at: "center", of: "svg"},
buttons: {}
});
}
function downloadDiplomacyData() {
const states = pack.states.filter(s => s.i && !s.removed);
const valid = states.map(s => s.i);
let data = "," + states.map(s => s.name).join(",") + "\n"; // headers
states.forEach(s => {
const rels = s.diplomacy.filter((v, i) => valid.includes(i));
data += s.name + "," + rels.join(",") + "\n";
});
const name = getFileName("Relations") + ".csv";
downloadFile(data, name);
}
function closeDiplomacyEditor() {
restoreDefaultEvents();
clearMainTip();
const selected = body.querySelector("div.Self");
if (selected) selected.classList.remove("Self");
if (layerIsOn("toggleStates")) drawStates();
else toggleStates();
debug.selectAll(".highlight").remove();
}
}

1044
src/modules/ui/editors.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,422 @@
import {findCell} from "/src/utils/graphUtils";
import {rn} from "/src/utils/numberUtils";
export function showEPForRoute(node) {
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
points.push(i);
});
const routeLen = node.getTotalLength() * distanceScaleInput.value;
showElevationProfile(points, routeLen, false);
}
export function showEPForRiver(node) {
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
points.push(i);
});
const riverLen = (node.getTotalLength() / 2) * distanceScaleInput.value;
showElevationProfile(points, riverLen, true);
}
function showElevationProfile(data, routeLen, isRiver) {
// data is an array of cell indexes, routeLen is the distance (in actual metres/feet), isRiver should be true for rivers, false otherwise
document.getElementById("epScaleRange").addEventListener("change", draw);
document.getElementById("epCurve").addEventListener("change", draw);
document.getElementById("epSave").addEventListener("click", downloadCSV);
$("#elevationProfile").dialog({
title: "Elevation profile",
resizable: false,
width: window.width,
close: closeElevationProfile,
position: {my: "left top", at: "left+20 bottom-500", of: window, collision: "fit"}
});
// prevent river graphs from showing rivers as flowing uphill - remember the general slope
let slope = 0;
if (isRiver) {
if (pack.cells.h[data[0]] < pack.cells.h[data[data.length - 1]]) {
slope = 1; // up-hill
} else if (pack.cells.h[data[0]] > pack.cells.h[data[data.length - 1]]) {
slope = -1; // down-hill
}
}
const chartWidth = window.innerWidth - 180,
chartHeight = 300; // height of our land/sea profile, excluding the biomes data below
const xOffset = 80,
yOffset = 80; // this is our drawing starting point from top-left (y = 0) of SVG
const biomesHeight = 40;
let lastBurgIndex = 0;
let lastBurgCell = 0;
let burgCount = 0;
let chartData = {biome: [], burg: [], cell: [], height: [], mi: 1000000, ma: 0, mih: 100, mah: 0, points: []};
for (let i = 0, prevB = 0, prevH = -1; i < data.length; i++) {
let cell = data[i];
let h = pack.cells.h[cell];
if (h < 20) {
const f = pack.features[pack.cells.f[cell]];
if (f.type === "lake") h = f.height;
else h = 20;
}
// check for river up-hill
if (prevH != -1) {
if (isRiver) {
if (slope == 1 && h < prevH) h = prevH;
else if (slope == 0 && h != prevH) h = prevH;
else if (slope == -1 && h > prevH) h = prevH;
}
}
prevH = h;
let b = pack.cells.burg[cell];
if (b == prevB) b = 0;
else prevB = b;
if (b) {
burgCount++;
lastBurgIndex = i;
lastBurgCell = cell;
}
chartData.biome[i] = pack.cells.biome[cell];
chartData.burg[i] = b;
chartData.cell[i] = cell;
let sh = getHeight(h);
chartData.height[i] = parseInt(sh.substr(0, sh.indexOf(" ")));
chartData.mih = Math.min(chartData.mih, h);
chartData.mah = Math.max(chartData.mah, h);
chartData.mi = Math.min(chartData.mi, chartData.height[i]);
chartData.ma = Math.max(chartData.ma, chartData.height[i]);
}
if (lastBurgIndex != 0 && lastBurgCell == chartData.cell[data.length - 1] && lastBurgIndex < data.length - 1) {
chartData.burg[data.length - 1] = chartData.burg[lastBurgIndex];
chartData.burg[lastBurgIndex] = 0;
}
draw();
function downloadCSV() {
let data =
"Point,X,Y,Cell,Height,Height value,Population,Burg,Burg population,Biome,Biome color,Culture,Culture color,Religion,Religion color,Province,Province color,State,State color\n"; // headers
for (let k = 0; k < chartData.points.length; k++) {
let cell = chartData.cell[k];
let burg = pack.cells.burg[cell];
let biome = pack.cells.biome[cell];
let culture = pack.cells.culture[cell];
let religion = pack.cells.religion[cell];
let province = pack.cells.province[cell];
let state = pack.cells.state[cell];
let pop = pack.cells.pop[cell];
let h = pack.cells.h[cell];
data += k + 1 + ",";
data += chartData.points[k][0] + ",";
data += chartData.points[k][1] + ",";
data += cell + ",";
data += getHeight(h) + ",";
data += h + ",";
data += rn(pop * populationRate) + ",";
if (burg) {
data += pack.burgs[burg].name + ",";
data += pack.burgs[burg].population * populationRate * urbanization + ",";
} else {
data += ",0,";
}
data += biomesData.name[biome] + ",";
data += biomesData.color[biome] + ",";
data += pack.cultures[culture].name + ",";
data += pack.cultures[culture].color + ",";
data += pack.religions[religion].name + ",";
data += pack.religions[religion].color + ",";
data += pack.provinces[province].name + ",";
data += pack.provinces[province].color + ",";
data += pack.states[state].name + ",";
data += pack.states[state].color + ",";
data = data + "\n";
}
const name = getFileName("elevation profile") + ".csv";
downloadFile(data, name);
}
function draw() {
chartData.points = [];
let heightScale = 100 / parseInt(epScaleRange.value);
heightScale *= 0.9; // curves cause the heights to go slightly higher, adjust here
const xscale = d3.scaleLinear().domain([0, data.length]).range([0, chartWidth]);
const yscale = d3
.scaleLinear()
.domain([0, chartData.ma * heightScale])
.range([chartHeight, 0]);
for (let i = 0; i < data.length; i++) {
chartData.points.push([xscale(i) + xOffset, yscale(chartData.height[i]) + yOffset]);
}
document.getElementById("elevationGraph").innerHTML = "";
const chart = d3
.select("#elevationGraph")
.append("svg")
.attr("width", chartWidth + 120)
.attr("height", chartHeight + yOffset + biomesHeight)
.attr("id", "elevationSVG")
.attr("class", "epbackground");
// arrow-head definition
chart
.append("defs")
.append("marker")
.attr("id", "arrowhead")
.attr("orient", "auto")
.attr("markerWidth", "2")
.attr("markerHeight", "4")
.attr("refX", "0.1")
.attr("refY", "2")
.append("path")
.attr("d", "M0,0 V4 L2,2 Z")
.attr("fill", "darkgray");
let colors = getColorScheme(terrs.attr("scheme"));
const landdef = chart
.select("defs")
.append("linearGradient")
.attr("id", "landdef")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
if (chartData.mah == chartData.mih) {
landdef
.append("stop")
.attr("offset", "0%")
.attr("style", "stop-color:" + getColor(chartData.mih, colors) + ";stop-opacity:1");
landdef
.append("stop")
.attr("offset", "100%")
.attr("style", "stop-color:" + getColor(chartData.mah, colors) + ";stop-opacity:1");
} else {
for (let k = chartData.mah; k >= chartData.mih; k--) {
let perc = 1 - (k - chartData.mih) / (chartData.mah - chartData.mih);
landdef
.append("stop")
.attr("offset", perc * 100 + "%")
.attr("style", "stop-color:" + getColor(k, colors) + ";stop-opacity:1");
}
}
// land
let curve = d3.line().curve(d3.curveBasis); // see https://github.com/d3/d3-shape#curves
let epCurveIndex = parseInt(epCurve.selectedIndex);
switch (epCurveIndex) {
case 0:
curve = d3.line().curve(d3.curveLinear);
break;
case 1:
curve = d3.line().curve(d3.curveBasis);
break;
case 2:
curve = d3.line().curve(d3.curveBundle.beta(1));
break;
case 3:
curve = d3.line().curve(d3.curveCatmullRom.alpha(0.5));
break;
case 4:
curve = d3.line().curve(d3.curveMonotoneX);
break;
case 5:
curve = d3.line().curve(d3.curveNatural);
break;
}
// copy the points so that we can add extra straight pieces, else we get curves at the ends of the chart
let extra = chartData.points.slice();
let path = curve(extra);
// this completes the right-hand side and bottom of our land "polygon"
path += " L" + parseInt(xscale(extra.length) + +xOffset) + "," + parseInt(extra[extra.length - 1][1]);
path += " L" + parseInt(xscale(extra.length) + +xOffset) + "," + parseInt(yscale(0) + +yOffset);
path += " L" + parseInt(xscale(0) + +xOffset) + "," + parseInt(yscale(0) + +yOffset);
path += "Z";
chart
.append("g")
.attr("id", "epland")
.append("path")
.attr("d", path)
.attr("stroke", "purple")
.attr("stroke-width", "0")
.attr("fill", "url(#landdef)");
// biome / heights
let g = chart.append("g").attr("id", "epbiomes");
const hu = heightUnit.value;
for (let k = 0; k < chartData.points.length; k++) {
const x = chartData.points[k][0];
const y = yOffset + chartHeight;
const c = biomesData.color[chartData.biome[k]];
const cell = chartData.cell[k];
const culture = pack.cells.culture[cell];
const religion = pack.cells.religion[cell];
const province = pack.cells.province[cell];
const state = pack.cells.state[cell];
let pop = pack.cells.pop[cell];
if (chartData.burg[k]) {
pop += pack.burgs[chartData.burg[k]].population * urbanization;
}
const populationDesc = rn(pop * populationRate);
const provinceDesc = province ? ", " + pack.provinces[province].name : "";
const dataTip =
biomesData.name[chartData.biome[k]] +
provinceDesc +
", " +
pack.states[state].name +
", " +
pack.religions[religion].name +
", " +
pack.cultures[culture].name +
" (height: " +
chartData.height[k] +
" " +
hu +
", population " +
populationDesc +
", cell " +
chartData.cell[k] +
")";
g.append("rect")
.attr("stroke", c)
.attr("fill", c)
.attr("x", x)
.attr("y", y)
.attr("width", xscale(1))
.attr("height", 15)
.attr("data-tip", dataTip);
}
const xAxis = d3
.axisBottom(xscale)
.ticks(10)
.tickFormat(function (d) {
return rn((d / chartData.points.length) * routeLen) + " " + distanceUnitInput.value;
});
const yAxis = d3
.axisLeft(yscale)
.ticks(5)
.tickFormat(function (d) {
return d + " " + hu;
});
const xGrid = d3.axisBottom(xscale).ticks(10).tickSize(-chartHeight).tickFormat("");
const yGrid = d3.axisLeft(yscale).ticks(5).tickSize(-chartWidth).tickFormat("");
chart
.append("g")
.attr("id", "epxaxis")
.attr("transform", "translate(" + xOffset + "," + parseInt(chartHeight + +yOffset + 20) + ")")
.call(xAxis)
.selectAll("text")
.style("text-anchor", "center")
.attr("transform", function (d) {
return "rotate(0)"; // used to rotate labels, - anti-clockwise, + clockwise
});
chart
.append("g")
.attr("id", "epyaxis")
.attr("transform", "translate(" + parseInt(+xOffset - 10) + "," + parseInt(+yOffset) + ")")
.call(yAxis);
// add the X gridlines
chart
.append("g")
.attr("id", "epxgrid")
.attr("class", "epgrid")
.attr("stroke-dasharray", "4 1")
.attr("transform", "translate(" + xOffset + "," + parseInt(chartHeight + +yOffset) + ")")
.call(xGrid);
// add the Y gridlines
chart
.append("g")
.attr("id", "epygrid")
.attr("class", "epgrid")
.attr("stroke-dasharray", "4 1")
.attr("transform", "translate(" + xOffset + "," + yOffset + ")")
.call(yGrid);
// draw city labels - try to avoid putting labels over one another
g = chart.append("g").attr("id", "epburglabels");
let y1 = 0;
const add = 15;
let xwidth = chartData.points[1][0] - chartData.points[0][0];
for (let k = 0; k < chartData.points.length; k++) {
if (chartData.burg[k] > 0) {
let b = chartData.burg[k];
let x1 = chartData.points[k][0]; // left side of graph by default
if (k > 0) x1 += xwidth / 2; // center it if not first
if (k == chartData.points.length - 1) x1 = chartWidth + xOffset; // right part of graph
y1 += add;
if (y1 >= yOffset) y1 = add;
// burg name
g.append("text")
.attr("id", "ep" + b)
.attr("class", "epburglabel")
.attr("x", x1)
.attr("y", y1)
.attr("text-anchor", "middle");
document.getElementById("ep" + b).innerHTML = pack.burgs[b].name;
// arrow from burg name to graph line
g.append("path")
.attr("id", "eparrow" + b)
.attr(
"d",
"M" +
x1.toString() +
"," +
(y1 + 3).toString() +
"L" +
x1.toString() +
"," +
parseInt(chartData.points[k][1] - 3).toString()
)
.attr("stroke", "darkgray")
.attr("fill", "lightgray")
.attr("stroke-width", "1")
.attr("marker-end", "url(#arrowhead)");
}
}
}
function closeElevationProfile() {
document.getElementById("epScaleRange").removeEventListener("change", draw);
document.getElementById("epCurve").removeEventListener("change", draw);
document.getElementById("epSave").removeEventListener("click", downloadCSV);
document.getElementById("elevationGraph").innerHTML = "";
fmg.modules.elevation = false;
}
}

View file

@ -0,0 +1,536 @@
import {clearMainTip} from "/src/scripts/tooltips";
import {tip} from "/src/scripts/tooltips";
import {openURL} from "/src/utils/linkUtils";
import {rn} from "/src/utils/numberUtils";
import {parseTransform} from "/src/utils/stringUtils";
export function editEmblem(type, id, el) {
if (customization) return;
if (!id && d3.event) defineEmblemData(d3.event);
emblems.selectAll("use").call(d3.drag().on("drag", dragEmblem)).classed("draggable", true);
const emblemStates = document.getElementById("emblemStates");
const emblemProvinces = document.getElementById("emblemProvinces");
const emblemBurgs = document.getElementById("emblemBurgs");
const emblemShapeSelector = document.getElementById("emblemShapeSelector");
updateElementSelectors(type, id, el);
$("#emblemEditor").dialog({
title: "Edit Emblem",
resizable: true,
width: "18.2em",
height: "auto",
position: {my: "left top", at: "left+10 top+10", of: "svg", collision: "fit"},
close: closeEmblemEditor
});
// add listeners,then remove on closure
emblemStates.oninput = selectState;
emblemProvinces.oninput = selectProvince;
emblemBurgs.oninput = selectBurg;
emblemShapeSelector.oninput = changeShape;
document.getElementById("emblemSizeSlider").oninput = changeSize;
document.getElementById("emblemSizeNumber").oninput = changeSize;
document.getElementById("emblemsRegenerate").onclick = regenerate;
document.getElementById("emblemsArmoria").onclick = openInArmoria;
document.getElementById("emblemsUpload").onclick = toggleUpload;
document.getElementById("emblemsUploadImage").onclick = () => emblemImageToLoad.click();
document.getElementById("emblemsUploadSVG").onclick = () => emblemSVGToLoad.click();
document.getElementById("emblemImageToLoad").onchange = () => upload("image");
document.getElementById("emblemSVGToLoad").onchange = () => upload("svg");
document.getElementById("emblemsDownload").onclick = toggleDownload;
document.getElementById("emblemsDownloadSVG").onclick = () => download("svg");
document.getElementById("emblemsDownloadPNG").onclick = () => download("png");
document.getElementById("emblemsDownloadJPG").onclick = () => download("jpeg");
document.getElementById("emblemsGallery").onclick = downloadGallery;
document.getElementById("emblemsFocus").onclick = showArea;
function defineEmblemData(e) {
const parent = e.target.parentNode;
const [g, t] =
parent.id === "burgEmblems"
? [pack.burgs, "burg"]
: parent.id === "provinceEmblems"
? [pack.provinces, "province"]
: [pack.states, "state"];
const i = +e.target.dataset.i;
type = t;
id = type + "COA" + i;
el = g[i];
}
function updateElementSelectors(type, id, el) {
let state = 0,
province = 0,
burg = 0;
// set active type
emblemStates.parentElement.className = type === "state" ? "active" : "";
emblemProvinces.parentElement.className = type === "province" ? "active" : "";
emblemBurgs.parentElement.className = type === "burg" ? "active" : "";
// define selected values
if (type === "state") state = el.i;
else if (type === "province") {
province = el.i;
state = pack.states[el.state].i;
} else {
burg = el.i;
province = pack.cells.province[el.cell] ? pack.provinces[pack.cells.province[el.cell]].i : 0;
state = el.state;
}
const validBurgs = pack.burgs.filter(burg => burg.i && !burg.removed && burg.coa);
// update option list and select actual values
emblemStates.options.length = 0;
const neutralBurgs = validBurgs.filter(burg => !burg.state);
if (neutralBurgs.length) emblemStates.options.add(new Option(pack.states[0].name, 0, false, !state));
const stateList = pack.states.filter(state => state.i && !state.removed);
stateList.forEach(s => emblemStates.options.add(new Option(s.name, s.i, false, s.i === state)));
emblemProvinces.options.length = 0;
emblemProvinces.options.add(new Option("", 0, false, !province));
const provinceList = pack.provinces.filter(province => !province.removed && province.state === state);
provinceList.forEach(p => emblemProvinces.options.add(new Option(p.name, p.i, false, p.i === province)));
emblemBurgs.options.length = 0;
emblemBurgs.options.add(new Option("", 0, false, !burg));
const burgList = validBurgs.filter(burg =>
province ? pack.cells.province[burg.cell] === province : burg.state === state
);
burgList.forEach(b =>
emblemBurgs.options.add(new Option(b.capital ? "👑 " + b.name : b.name, b.i, false, b.i === burg))
);
emblemBurgs.options[0].disabled = true;
COArenderer.trigger(id, el.coa);
updateEmblemData(type, id, el);
}
function updateEmblemData(type, id, el) {
if (!el.coa) return;
document.getElementById("emblemImage").setAttribute("href", "#" + id);
let name = el.fullName || el.name;
if (type === "burg") name = "Burg of " + name;
document.getElementById("emblemArmiger").innerText = name;
if (el.coa === "custom") emblemShapeSelector.disabled = true;
else {
emblemShapeSelector.disabled = false;
emblemShapeSelector.value = el.coa.shield;
}
const size = el.coaSize || 1;
document.getElementById("emblemSizeSlider").value = size;
document.getElementById("emblemSizeNumber").value = size;
}
function selectState() {
const state = +this.value;
if (state) {
type = "state";
el = pack.states[state];
id = "stateCOA" + state;
} else {
// select neutral burg if state is changed to Neutrals
const neutralBurgs = pack.burgs.filter(burg => burg.i && !burg.removed && !burg.state);
if (!neutralBurgs.length) return;
type = "burg";
el = neutralBurgs[0];
id = "burgCOA" + neutralBurgs[0].i;
}
updateElementSelectors(type, id, el);
}
function selectProvince() {
const province = +this.value;
if (province) {
type = "province";
el = pack.provinces[province];
id = "provinceCOA" + province;
} else {
// select state if province is changed to null value
const state = +emblemStates.value;
type = "state";
el = pack.states[state];
id = "stateCOA" + state;
}
updateElementSelectors(type, id, el);
}
function selectBurg() {
const burg = +this.value;
type = "burg";
el = pack.burgs[burg];
id = "burgCOA" + burg;
updateElementSelectors(type, id, el);
}
function changeShape() {
el.coa.shield = this.value;
const coaEl = document.getElementById(id);
if (coaEl) coaEl.remove();
COArenderer.trigger(id, el.coa);
}
function showArea() {
highlightEmblemElement(type, el);
}
function changeSize() {
const size = (el.coaSize = +this.value);
document.getElementById("emblemSizeSlider").value = size;
document.getElementById("emblemSizeNumber").value = size;
const g = emblems.select("#" + type + "Emblems");
g.select("[data-i='" + el.i + "']").remove();
if (!size) return;
// re-append use element
const categotySize = +g.attr("font-size");
const shift = (categotySize * size) / 2;
const x = el.x || el.pole[0];
const y = el.y || el.pole[1];
g.append("use")
.attr("data-i", el.i)
.attr("x", rn(x - shift), 2)
.attr("y", rn(y - shift), 2)
.attr("width", size + "em")
.attr("height", size + "em")
.attr("href", "#" + id);
}
function regenerate() {
let parent = null;
if (type === "province") parent = pack.states[el.state];
else if (type === "burg") {
const province = pack.cells.province[el.cell];
parent = province ? pack.provinces[province] : pack.states[el.state];
}
const shield = el.coa.shield || COA.getShield(el.culture || parent?.culture || 0, el.state);
el.coa = COA.generate(parent ? parent.coa : null, 0.3, 0.1, null);
el.coa.shield = shield;
emblemShapeSelector.disabled = false;
emblemShapeSelector.value = el.coa.shield;
const coaEl = document.getElementById(id);
if (coaEl) coaEl.remove();
COArenderer.trigger(id, el.coa);
}
function openInArmoria() {
const coa = el.coa && el.coa !== "custom" ? el.coa : {t1: "sable"};
const json = JSON.stringify(coa).replaceAll("#", "%23");
const url = `https://azgaar.github.io/Armoria/?coa=${json}&from=FMG`;
openURL(url);
}
function toggleUpload() {
document.getElementById("emblemDownloadControl").classList.add("hidden");
const buttons = document.getElementById("emblemUploadControl");
buttons.classList.toggle("hidden");
}
function upload(type) {
const input =
type === "image" ? document.getElementById("emblemImageToLoad") : document.getElementById("emblemSVGToLoad");
const file = input.files[0];
input.value = "";
if (file.size > 500000) {
tip(
`File is too big, please optimize file size up to 500kB and re-upload. Recommended size is 200x200 px and up to 100kB`,
true,
"error",
5000
);
return;
}
const reader = new FileReader();
reader.onload = function (readerEvent) {
const result = readerEvent.target.result;
const defs = document.getElementById("defs-emblems");
const coa = document.getElementById(id); // old emblem
if (type === "image") {
const svg = `<svg id="${id}" xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><image x="0" y="0" width="200" height="200" href="${result}"/></svg>`;
defs.insertAdjacentHTML("beforeend", svg);
} else {
const el = document.createElement("html");
el.innerHTML = result;
// remove sodipodi and inkscape attributes
el.querySelectorAll("*").forEach(el => {
const attributes = el.getAttributeNames();
attributes.forEach(attr => {
if (attr.includes("inkscape") || attr.includes("sodipodi")) el.removeAttribute(attr);
});
});
const svg = el.querySelector("svg");
if (!svg) {
tip(
"The file should be prepated for load to FMG. Please use Armoria or other relevant tools",
false,
"error"
);
return;
}
const newEmblem = defs.appendChild(svg);
newEmblem.id = id;
newEmblem.setAttribute("width", 200);
newEmblem.setAttribute("height", 200);
}
if (coa) coa.remove(); // remove old emblem
el.coa = "custom";
emblemShapeSelector.disabled = true;
};
if (type === "image") reader.readAsDataURL(file);
else reader.readAsText(file);
}
function toggleDownload() {
document.getElementById("emblemUploadControl").classList.add("hidden");
const buttons = document.getElementById("emblemDownloadControl");
buttons.classList.toggle("hidden");
}
async function download(format) {
const coa = document.getElementById(id);
const size = +emblemsDownloadSize.value;
const url = await getURL(coa, size);
const link = document.createElement("a");
link.download = getFileName(`Emblem ${el.fullName || el.name}`) + "." + format;
if (format === "svg") downloadSVG(url, link);
else downloadRaster(format, url, link, size);
document.getElementById("emblemDownloadControl").classList.add("hidden");
}
function downloadSVG(url, link) {
link.href = url;
link.click();
}
function downloadRaster(format, url, link, size) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = size;
canvas.height = size;
const img = new Image();
img.src = url;
img.onload = function () {
if (format === "jpeg") {
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const dataURL = canvas.toDataURL("image/" + format, 0.92);
link.href = dataURL;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(dataURL), 6000);
};
}
async function getURL(svg, size) {
const serialized = getSVG(svg, size);
const blob = new Blob([serialized], {type: "image/svg+xml;charset=utf-8"});
const url = window.URL.createObjectURL(blob);
window.setTimeout(() => window.URL.revokeObjectURL(url), 6000);
return url;
}
function getSVG(svg, size) {
const clone = svg.cloneNode(true);
clone.setAttribute("width", size);
clone.setAttribute("height", size);
return new XMLSerializer().serializeToString(clone);
}
async function downloadGallery() {
const name = getFileName("Emblems Gallery");
const validStates = pack.states.filter(s => s.i && !s.removed && s.coa);
const validProvinces = pack.provinces.filter(p => p.i && !p.removed && p.coa);
const validBurgs = pack.burgs.filter(b => b.i && !b.removed && b.coa);
await renderAllEmblems(validStates, validProvinces, validBurgs);
runDownload();
function runDownload() {
const back = `<a href="javascript:history.back()">Go Back</a>`;
const stateSection =
`<div><h2>States</h2>` +
validStates
.map(state => {
const el = document.getElementById("stateCOA" + state.i);
return `<figure id="state_${state.i}"><a href="#provinces_${state.i}"><figcaption>${
state.fullName
}</figcaption>${getSVG(el, 200)}</a></figure>`;
})
.join("") +
`</div>`;
const provinceSections = validStates
.map(state => {
const stateProvinces = validProvinces.filter(p => p.state === state.i);
const figures = stateProvinces
.map(province => {
const el = document.getElementById("provinceCOA" + province.i);
return `<figure id="province_${province.i}"><a href="#burgs_${province.i}"><figcaption>${
province.fullName
}</figcaption>${getSVG(el, 200)}</a></figure>`;
})
.join("");
return stateProvinces.length
? `<div id="provinces_${state.i}">${back}<h2>${state.fullName} provinces</h2>${figures}</div>`
: "";
})
.join("");
const burgSections = validStates
.map(state => {
const stateBurgs = validBurgs.filter(b => b.state === state.i);
let stateBurgSections = validProvinces
.filter(p => p.state === state.i)
.map(province => {
const provinceBurgs = stateBurgs.filter(b => pack.cells.province[b.cell] === province.i);
const provinceBurgFigures = provinceBurgs
.map(burg => {
const el = document.getElementById("burgCOA" + burg.i);
return `<figure id="burg_${burg.i}"><figcaption>${burg.name}</figcaption>${getSVG(el, 200)}</figure>`;
})
.join("");
return provinceBurgs.length
? `<div id="burgs_${province.i}">${back}<h2>${province.fullName} burgs</h2>${provinceBurgFigures}</div>`
: "";
})
.join("");
const stateBurgOutOfProvinces = stateBurgs.filter(b => !pack.cells.province[b.cell]);
const stateBurgOutOfProvincesFigures = stateBurgOutOfProvinces
.map(burg => {
const el = document.getElementById("burgCOA" + burg.i);
return `<figure id="burg_${burg.i}"><figcaption>${burg.name}</figcaption>${getSVG(el, 200)}</figure>`;
})
.join("");
if (stateBurgOutOfProvincesFigures)
stateBurgSections += `<div><h2>${state.fullName} burgs under direct control</h2>${stateBurgOutOfProvincesFigures}</div>`;
return stateBurgSections;
})
.join("");
const neutralBurgs = validBurgs.filter(b => !b.state);
const neutralsSection = neutralBurgs.length
? "<div><h2>Independent burgs</h2>" +
neutralBurgs
.map(burg => {
const el = document.getElementById("burgCOA" + burg.i);
return `<figure id="burg_${burg.i}"><figcaption>${burg.name}</figcaption>${getSVG(el, 200)}</figure>`;
})
.join("") +
"</div>"
: "";
const FMG = `<a href="https://azgaar.github.io/Fantasy-Map-Generator" target="_blank">Azgaar's Fantasy Map Generator</a>`;
const license = `<a target="_blank" href="https://github.com/Azgaar/Armoria#license">the license</a>`;
const html = /* html */ `<!DOCTYPE html>
<html>
<head>
<title>${mapName.value} Emblems Gallery</title>
</head>
<style type="text/css">
body {
margin: 0;
padding: 1em;
font-family: serif;
}
h1,
h2 {
font-family: "Forum";
}
div {
width: 100%;
max-width: 1018px;
margin: 0 auto;
border-bottom: 1px solid #ddd;
}
figure {
margin: 0 0 2em;
display: inline-block;
transition: 0.2s;
}
figure:hover {
background-color: #f6f6f6;
}
figcaption {
text-align: center;
margin: 0.4em 0;
width: 200px;
font-family: "Overlock SC";
}
address {
width: 100%;
max-width: 1018px;
margin: 0 auto;
}
a {
color: black;
}
figure > a {
text-decoration: none;
}
div > a {
float: right;
font-family: monospace;
margin-top: 0.8em;
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Forum&family=Overlock+SC" rel="stylesheet" />
<body>
<div><h1>${mapName.value} Emblems Gallery</h1></div>
${stateSection} ${provinceSections} ${burgSections} ${neutralsSection}
<address>Generated by ${FMG}. The tool is free, but images may be copyrighted, see ${license}</address>
</body>
</html>`;
downloadFile(html, name + ".html", "text/plain");
}
}
async function renderAllEmblems(states, provinces, burgs) {
tip("Preparing for download...", true, "warn");
const statePromises = states.map(state => COArenderer.trigger("stateCOA" + state.i, state.coa));
const provincePromises = provinces.map(province => COArenderer.trigger("provinceCOA" + province.i, province.coa));
const burgPromises = burgs.map(burg => COArenderer.trigger("burgCOA" + burg.i, burg.coa));
const promises = [...statePromises, ...provincePromises, ...burgPromises];
return Promise.allSettled(promises).then(res => clearMainTip());
}
function dragEmblem() {
const tr = parseTransform(this.getAttribute("transform"));
const x = +tr[0] - d3.event.x,
y = +tr[1] - d3.event.y;
d3.event.on("drag", function () {
const transform = `translate(${x + d3.event.x},${y + d3.event.y})`;
this.setAttribute("transform", transform);
});
}
function closeEmblemEditor() {
emblems.selectAll("use").call(d3.drag().on("drag", null)).attr("class", null);
}
}

269
src/modules/ui/general.js Normal file
View file

@ -0,0 +1,269 @@
import {findCell, findGridCell} from "/src/utils/graphUtils";
import {rn} from "/src/utils/numberUtils";
import {link} from "/src/utils/linkUtils";
import {getCoordinates, toDMS} from "/src/utils/coordinateUtils";
import {si} from "/src/utils/unitUtils";
// fit full-screen map if window is resized
window.addEventListener("resize", function (e) {
if (stored("mapWidth") && stored("mapHeight")) return;
mapWidthInput.value = window.innerWidth;
mapHeightInput.value = window.innerHeight;
changeMapSize();
});
if (location.hostname && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
window.onbeforeunload = () => "Are you sure you want to navigate away?";
}
function highlightEditorLine(editor, id, timeout = 10000) {
Array.from(editor.getElementsByClassName("states hovered")).forEach(el => el.classList.remove("hovered")); // clear all hovered
const hovered = Array.from(editor.querySelectorAll("div")).find(el => el.dataset.id == id);
if (hovered) hovered.classList.add("hovered"); // add hovered class
if (timeout)
setTimeout(() => {
hovered && hovered.classList.remove("hovered");
}, timeout);
}
// get cell info on mouse move
function updateCellInfo(point, i, g) {
const cells = pack.cells;
const x = (infoX.innerHTML = rn(point[0]));
const y = (infoY.innerHTML = rn(point[1]));
const f = cells.f[i];
const [lon, lat] = getCoordinates(x, y, 4);
infoLat.innerHTML = toDMS(lat, "lat");
infoLon.innerHTML = toDMS(lon, "lon");
infoCell.innerHTML = i;
infoArea.innerHTML = cells.area[i] ? si(getArea(cells.area[i])) + " " + getAreaUnit() : "n/a";
infoEvelation.innerHTML = getElevation(pack.features[f], pack.cells.h[i]);
infoDepth.innerHTML = getDepth(pack.features[f], point);
infoTemp.innerHTML = convertTemperature(grid.cells.temp[g]);
infoPrec.innerHTML = cells.h[i] >= 20 ? getFriendlyPrecipitation(i) : "n/a";
infoRiver.innerHTML = cells.h[i] >= 20 && cells.r[i] ? getRiverInfo(cells.r[i]) : "no";
infoState.innerHTML =
cells.h[i] >= 20
? cells.state[i]
? `${pack.states[cells.state[i]].fullName} (${cells.state[i]})`
: "neutral lands (0)"
: "no";
infoProvince.innerHTML = cells.province[i]
? `${pack.provinces[cells.province[i]].fullName} (${cells.province[i]})`
: "no";
infoCulture.innerHTML = cells.culture[i] ? `${pack.cultures[cells.culture[i]].name} (${cells.culture[i]})` : "no";
infoReligion.innerHTML = cells.religion[i]
? `${pack.religions[cells.religion[i]].name} (${cells.religion[i]})`
: "no";
infoPopulation.innerHTML = getFriendlyPopulation(i);
infoBurg.innerHTML = cells.burg[i] ? pack.burgs[cells.burg[i]].name + " (" + cells.burg[i] + ")" : "no";
infoFeature.innerHTML = f ? pack.features[f].group + " (" + f + ")" : "n/a";
infoBiome.innerHTML = biomesData.name[cells.biome[i]];
}
// get surface elevation
function getElevation(f, h) {
if (f.land) return getHeight(h) + " (" + h + ")"; // land: usual height
if (f.border) return "0 " + heightUnit.value; // ocean: 0
if (f.type === "lake") return getHeight(f.height) + " (" + f.height + ")"; // lake: defined on river generation
}
// get water depth
function getDepth(f, p) {
if (f.land) return "0 " + heightUnit.value; // land: 0
// lake: difference between surface and bottom
const gridH = grid.cells.h[findGridCell(p[0], p[1], grid)];
if (f.type === "lake") {
const depth = gridH === 19 ? f.height / 2 : gridH;
return getHeight(depth, "abs");
}
return getHeight(gridH, "abs"); // ocean: grid height
}
// get user-friendly (real-world) height value from map data
export function getFriendlyHeight([x, y]) {
const packH = pack.cells.h[findCell(x, y)];
const gridH = grid.cells.h[findGridCell(x, y, grid)];
const h = packH < 20 ? gridH : packH;
return getHeight(h);
}
function getHeight(h, abs) {
const unit = heightUnit.value;
let unitRatio = 3.281; // default calculations are in feet
if (unit === "m") unitRatio = 1; // if meter
else if (unit === "f") unitRatio = 0.5468; // if fathom
let height = -990;
if (h >= 20) height = Math.pow(h - 18, +heightExponentInput.value);
else if (h < 20 && h > 0) height = ((h - 20) / h) * 50;
if (abs) height = Math.abs(height);
return rn(height * unitRatio) + " " + unit;
}
function getPrecipitation(prec) {
return prec * 100 + " mm";
}
// get user-friendly (real-world) precipitation value from map data
function getFriendlyPrecipitation(i) {
const prec = grid.cells.prec[pack.cells.g[i]];
return getPrecipitation(prec);
}
function getRiverInfo(id) {
const r = pack.rivers.find(r => r.i == id);
return r ? `${r.name} ${r.type} (${id})` : "n/a";
}
function getCellPopulation(i) {
const rural = pack.cells.pop[i] * populationRate;
const urban = pack.cells.burg[i] ? pack.burgs[pack.cells.burg[i]].population * populationRate * urbanization : 0;
return [rural, urban];
}
// get user-friendly (real-world) population value from map data
function getFriendlyPopulation(i) {
const [rural, urban] = getCellPopulation(i);
return `${si(rural + urban)} (${si(rural)} rural, urban ${si(urban)})`;
}
function getPopulationTip(i) {
const [rural, urban] = getCellPopulation(i);
return `Cell population: ${si(rural + urban)}; Rural: ${si(rural)}; Urban: ${si(urban)}`;
}
function highlightEmblemElement(type, el) {
const i = el.i,
cells = pack.cells;
const animation = d3.transition().duration(1000).ease(d3.easeSinIn);
if (type === "burg") {
const {x, y} = el;
debug
.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 0)
.attr("fill", "none")
.attr("stroke", "#d0240f")
.attr("stroke-width", 1)
.attr("opacity", 1)
.transition(animation)
.attr("r", 20)
.attr("opacity", 0.1)
.attr("stroke-width", 0)
.remove();
return;
}
const [x, y] = el.pole || pack.cells.p[el.center];
const obj = type === "state" ? cells.state : cells.province;
const borderCells = cells.i.filter(id => obj[id] === i && cells.c[id].some(n => obj[n] !== i));
const data = Array.from(borderCells)
.filter((c, i) => !(i % 2))
.map(i => cells.p[i])
.map(i => [i[0], i[1], Math.hypot(i[0] - x, i[1] - y)]);
debug
.selectAll("line")
.data(data)
.enter()
.append("line")
.attr("x1", x)
.attr("y1", y)
.attr("x2", d => d[0])
.attr("y2", d => d[1])
.attr("stroke", "#d0240f")
.attr("stroke-width", 0.5)
.attr("opacity", 0.2)
.attr("stroke-dashoffset", d => d[2])
.attr("stroke-dasharray", d => d[2])
.transition(animation)
.attr("stroke-dashoffset", 0)
.attr("opacity", 1)
.transition(animation)
.delay(1000)
.attr("stroke-dashoffset", d => d[2])
.attr("opacity", 0)
.remove();
}
// assign skeaker behaviour
Array.from(document.getElementsByClassName("speaker")).forEach(el => {
const input = el.previousElementSibling;
el.addEventListener("click", () => speak(input.value));
});
function speak(text) {
const speaker = new SpeechSynthesisUtterance(text);
const voices = speechSynthesis.getVoices();
if (voices.length) {
const voiceId = +document.getElementById("speakerVoice").value;
speaker.voice = voices[voiceId];
}
speechSynthesis.speak(speaker);
}
// apply drop-down menu option. If the value is not in options, add it
export function applyOption($select, value, name = value) {
const isExisting = Array.from($select.options).some(o => o.value === value);
if (!isExisting) $select.options.add(new Option(name, value));
$select.value = value;
}
// show info about the generator in a popup
function showInfo() {
const Discord = link("https://discordapp.com/invite/X7E84HU", "Discord");
const Reddit = link("https://www.reddit.com/r/FantasyMapGenerator", "Reddit");
const Patreon = link("https://www.patreon.com/azgaar", "Patreon");
const Armoria = link("https://azgaar.github.io/Armoria", "Armoria");
const QuickStart = link(
"https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Quick-Start-Tutorial",
"Quick start tutorial"
);
const QAA = link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Q&A", "Q&A page");
const VideoTutorial = link("https://youtube.com/playlist?list=PLtgiuDC8iVR2gIG8zMTRn7T_L0arl9h1C", "Video tutorial");
alertMessage.innerHTML = /* html */ `<b>Fantasy Map Generator</b> (FMG) is a free open-source application. It means that you own all created maps and can use them as
you wish.
<p>
The development is community-backed, you can donate on ${Patreon}. You can also help creating overviews, tutorials and spreding the word about the
Generator.
</p>
<p>
The best way to get help is to contact the community on ${Discord} and ${Reddit}. Before asking questions, please check out the ${QuickStart}, the ${QAA},
and ${VideoTutorial}.
</p>
<p>Check out our another project: ${Armoria} heraldry generator and editor.</p>
<ul style="columns:2">
<li>${link("https://github.com/Azgaar/Fantasy-Map-Generator", "GitHub repository")}</li>
<li>${link("https://github.com/Azgaar/Fantasy-Map-Generator/blob/master/LICENSE", "License")}</li>
<li>${link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog", "Changelog")}</li>
<li>${link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Hotkeys", "Hotkeys")}</li>
<li>${link("https://trello.com/b/7x832DG4/fantasy-map-generator", "Devboard")}</li>
<li><a href="mailto:azgaar.fmg@yandex.by" target="_blank">Contact Azgaar</a></li>
</ul>`;
$("#alert").dialog({
resizable: false,
title: document.title,
width: "28em",
buttons: {
OK: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}

File diff suppressed because it is too large Load diff

173
src/modules/ui/hotkeys.js Normal file
View file

@ -0,0 +1,173 @@
"use strict";
// Hotkeys, see github.com/Azgaar/Fantasy-Map-Generator/wiki/Hotkeys
document.addEventListener("keydown", handleKeydown);
document.addEventListener("keyup", handleKeyup);
function handleKeydown(event) {
if (!allowHotkeys()) return; // in some cases (e.g. in a textarea) hotkeys are not allowed
const {code, ctrlKey, altKey} = event;
if (altKey && !ctrlKey) event.preventDefault(); // disallow alt key combinations
if (ctrlKey && ["KeyS", "KeyC"].includes(code)) event.preventDefault(); // disallow CTRL + S and CTRL + C
if (["F1", "F2", "F6", "F9", "Tab"].includes(code)) event.preventDefault(); // disallow default Fn and Tab
}
function handleKeyup(event) {
if (!allowHotkeys()) return; // in some cases (e.g. in a textarea) hotkeys are not allowed
event.stopPropagation();
const {code, key, ctrlKey, metaKey, shiftKey, altKey} = event;
const ctrl = ctrlKey || metaKey || key === "Control";
const shift = shiftKey || key === "Shift";
const alt = altKey || key === "Alt";
if (code === "F1") showInfo();
else if (code === "F2") regeneratePrompt();
else if (code === "F6") quickSave();
else if (code === "F9") quickLoad();
else if (code === "Tab") toggleOptions(event);
else if (code === "Escape") closeAllDialogs();
else if (code === "Delete") removeElementOnKey();
else if (code === "KeyO" && document.getElementById("canvas3d")) toggle3dOptions();
else if (ctrl && code === "KeyQ") toggleSaveReminder();
else if (ctrl && code === "KeyS") dowloadMap();
else if (ctrl && code === "KeyC") saveToDropbox();
else if (ctrl && code === "KeyZ" && undo?.offsetParent) undo.click();
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 === "KeyP") editProvinces();
else if (shift && code === "KeyD") editDiplomacy();
else if (shift && code === "KeyC") editCultures();
else if (shift && code === "KeyN") editNamesbase();
else if (shift && code === "KeyZ") editZones();
else if (shift && code === "KeyR") editReligions();
else if (shift && code === "KeyY") openEmblemEditor();
else if (shift && code === "KeyQ") editUnits();
else if (shift && code === "KeyO") editNotes();
else if (shift && code === "KeyA") overviewCharts();
else if (shift && code === "KeyT") overviewBurgs();
else if (shift && code === "KeyV") overviewRivers();
else if (shift && code === "KeyM") overviewMilitary();
else if (shift && code === "KeyK") overviewMarkers();
else if (shift && code === "KeyE") viewCellDetails();
else if (key === "!") toggleAddBurg();
else if (key === "@") toggleAddLabel();
else if (key === "#") toggleAddRiver();
else if (key === "$") toggleAddRoute();
else if (key === "%") toggleAddMarker();
else if (alt && code === "KeyB") console.table(pack.burgs);
else if (alt && code === "KeyS") console.table(pack.states);
else if (alt && code === "KeyC") console.table(pack.cultures);
else if (alt && code === "KeyR") console.table(pack.religions);
else if (alt && code === "KeyF") console.table(pack.features);
else if (code === "KeyX") toggleTexture();
else if (code === "KeyH") toggleHeight();
else if (code === "KeyB") toggleBiomes();
else if (code === "KeyE") toggleCells();
else if (code === "KeyG") toggleGrid();
else if (code === "KeyO") toggleCoordinates();
else if (code === "KeyW") toggleCompass();
else if (code === "KeyV") toggleRivers();
else if (code === "KeyF") toggleRelief();
else if (code === "KeyC") toggleCultures();
else if (code === "KeyS") toggleStates();
else if (code === "KeyP") toggleProvinces();
else if (code === "KeyZ") toggleZones();
else if (code === "KeyD") toggleBorders();
else if (code === "KeyR") toggleReligions();
else if (code === "KeyU") toggleRoutes();
else if (code === "KeyT") toggleTemp();
else if (code === "KeyN") togglePopulation();
else if (code === "KeyJ") toggleIce();
else if (code === "KeyA") togglePrec();
else if (code === "KeyY") toggleEmblems();
else if (code === "KeyL") toggleLabels();
else if (code === "KeyI") toggleIcons();
else if (code === "KeyM") toggleMilitary();
else if (code === "KeyK") toggleMarkers();
else if (code === "Equal") toggleRulers();
else if (code === "Slash") toggleScaleBar();
else if (code === "ArrowLeft") zoom.translateBy(svg, 10, 0);
else if (code === "ArrowRight") zoom.translateBy(svg, -10, 0);
else if (code === "ArrowUp") zoom.translateBy(svg, 0, 10);
else if (code === "ArrowDown") zoom.translateBy(svg, 0, -10);
else if (key === "+" || key === "-") pressNumpadSign(key);
else if (key === "0") resetZoom(1000);
else if (key === "1") zoom.scaleTo(svg, 1);
else if (key === "2") zoom.scaleTo(svg, 2);
else if (key === "3") zoom.scaleTo(svg, 3);
else if (key === "4") zoom.scaleTo(svg, 4);
else if (key === "5") zoom.scaleTo(svg, 5);
else if (key === "6") zoom.scaleTo(svg, 6);
else if (key === "7") zoom.scaleTo(svg, 7);
else if (key === "8") zoom.scaleTo(svg, 8);
else if (key === "9") zoom.scaleTo(svg, 9);
else if (ctrl) toggleMode();
}
function allowHotkeys() {
const {tagName, contentEditable} = document.activeElement;
if (["INPUT", "SELECT", "TEXTAREA"].includes(tagName)) return false;
if (tagName === "DIV" && contentEditable === "true") return false;
if (document.getSelection().toString()) return false;
return true;
}
function pressNumpadSign(key) {
const change = key === "+" ? 1 : -1;
let brush = null;
if (document.getElementById("brushRadius")?.offsetParent) brush = document.getElementById("brushRadius");
else if (document.getElementById("biomesManuallyBrush")?.offsetParent)
brush = document.getElementById("biomesManuallyBrush");
else if (document.getElementById("statesManuallyBrush")?.offsetParent)
brush = document.getElementById("statesManuallyBrush");
else if (document.getElementById("provincesManuallyBrush")?.offsetParent)
brush = document.getElementById("provincesManuallyBrush");
else if (document.getElementById("culturesManuallyBrush")?.offsetParent)
brush = document.getElementById("culturesManuallyBrush");
else if (document.getElementById("zonesBrush")?.offsetParent) brush = document.getElementById("zonesBrush");
else if (document.getElementById("religionsManuallyBrush")?.offsetParent)
brush = document.getElementById("religionsManuallyBrush");
if (brush) {
const value = minmax(+brush.value + change, +brush.min, +brush.max);
brush.value = document.getElementById(brush.id + "Number").value = value;
return;
}
const scaleBy = key === "+" ? 1.2 : 0.8;
zoom.scaleBy(svg, scaleBy); // if no brush elements displayed, zoom map
}
function toggleMode() {
if (zonesRemove?.offsetParent) {
zonesRemove.classList.contains("pressed")
? zonesRemove.classList.remove("pressed")
: zonesRemove.classList.add("pressed");
}
}
function removeElementOnKey() {
const fastDelete = Array.from(document.querySelectorAll("[role='dialog'] .fastDelete")).find(
dialog => dialog.style.display !== "none"
);
if (fastDelete) fastDelete.click();
const visibleDialogs = Array.from(document.querySelectorAll("[role='dialog']")).filter(
dialog => dialog.style.display !== "none"
);
if (!visibleDialogs.length) return;
visibleDialogs.forEach(dialog =>
dialog.querySelectorAll("button").forEach(button => button.textContent === "Remove" && button.click())
);
}
function closeAllDialogs() {
closeDialogs();
hideOptions();
}

View file

@ -0,0 +1,121 @@
import {findGridCell, getGridPolygon} from "/src/utils/graphUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {ra} from "/src/utils/probabilityUtils";
import {parseTransform} from "/src/utils/stringUtils";
export function editIce() {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleIce")) toggleIce();
elSelected = d3.select(d3.event.target);
const type = elSelected.attr("type") ? "Glacier" : "Iceberg";
document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block";
document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block";
if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size");
ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement));
$("#iceEditor").dialog({
title: "Edit " + type,
resizable: false,
position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"},
close: closeEditor
});
if (fmg.modules.editIce) return;
fmg.modules.editIce = true;
// add listeners
document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice"));
document.getElementById("iceRandomize").addEventListener("click", randomizeShape);
document.getElementById("iceSize").addEventListener("input", changeSize);
document.getElementById("iceNew").addEventListener("click", toggleAdd);
document.getElementById("iceRemove").addEventListener("click", removeIce);
function randomizeShape() {
const c = grid.points[+elSelected.attr("cell")];
const s = +elSelected.attr("size");
const i = ra(grid.cells.i),
cn = grid.points[i];
const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]);
const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]);
elSelected.attr("points", points);
}
function changeSize() {
const c = grid.points[+elSelected.attr("cell")];
const s = +elSelected.attr("size");
const flat = elSelected
.attr("points")
.split(",")
.map(el => +el);
const pairs = [];
while (flat.length) pairs.push(flat.splice(0, 2));
const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]);
const size = +this.value;
const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]);
elSelected.attr("points", points).attr("size", size);
}
function toggleAdd() {
document.getElementById("iceNew").classList.toggle("pressed");
if (document.getElementById("iceNew").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", addIcebergOnClick);
tip("Click on map to create an iceberg. Hold Shift to add multiple", true);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
}
}
function addIcebergOnClick() {
const [x, y] = d3.mouse(this);
const i = findGridCell(x, y, grid);
const c = grid.points[i];
const s = +document.getElementById("iceSize").value;
const points = getGridPolygon(i).map(p => [(p[0] + (c[0] - p[0]) / s) | 0, (p[1] + (c[1] - p[1]) / s) | 0]);
const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", s);
iceberg.call(d3.drag().on("drag", dragElement));
if (d3.event.shiftKey === false) toggleAdd();
}
function removeIce() {
const type = elSelected.attr("type") ? "Glacier" : "Iceberg";
alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`;
$("#alert").dialog({
resizable: false,
title: "Remove " + type,
buttons: {
Remove: function () {
$(this).dialog("close");
elSelected.remove();
$("#iceEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function dragElement() {
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})`);
});
}
function closeEditor() {
ice.selectAll("*").classed("draggable", false).call(d3.drag().on("drag", null));
clearMainTip();
iceNew.classList.remove("pressed");
unselect();
}
}

View file

@ -0,0 +1,410 @@
import {findCell} from "/src/utils/graphUtils";
import {tip, showMainTip} from "/src/scripts/tooltips";
import {round, parseTransform} from "/src/utils/stringUtils";
export function editLabel() {
if (customization) return;
closeDialogs();
if (!layerIsOn("toggleLabels")) toggleLabels();
const tspan = d3.event.target;
const textPath = tspan.parentNode;
const text = textPath.parentNode;
elSelected = d3.select(text).call(d3.drag().on("start", dragLabel)).classed("draggable", true);
viewbox.on("touchmove mousemove", showEditorTips);
$("#labelEditor").dialog({
title: "Edit Label",
resizable: false,
width: "fit-content",
position: {my: "center top+10", at: "bottom", of: text, collision: "fit"},
close: closeLabelEditor
});
drawControlPointsAndLine();
selectLabelGroup(text);
updateValues(textPath);
if (fmg.modules.editLabel) return;
fmg.modules.editLabel = true;
// add listeners
document.getElementById("labelGroupShow").addEventListener("click", showGroupSection);
document.getElementById("labelGroupHide").addEventListener("click", hideGroupSection);
document.getElementById("labelGroupSelect").addEventListener("click", changeGroup);
document.getElementById("labelGroupInput").addEventListener("change", createNewGroup);
document.getElementById("labelGroupNew").addEventListener("click", toggleNewGroupInput);
document.getElementById("labelGroupRemove").addEventListener("click", removeLabelsGroup);
document.getElementById("labelTextShow").addEventListener("click", showTextSection);
document.getElementById("labelTextHide").addEventListener("click", hideTextSection);
document.getElementById("labelText").addEventListener("input", changeText);
document.getElementById("labelTextRandom").addEventListener("click", generateRandomName);
document.getElementById("labelEditStyle").addEventListener("click", editGroupStyle);
document.getElementById("labelSizeShow").addEventListener("click", showSizeSection);
document.getElementById("labelSizeHide").addEventListener("click", hideSizeSection);
document.getElementById("labelStartOffset").addEventListener("input", changeStartOffset);
document.getElementById("labelRelativeSize").addEventListener("input", changeRelativeSize);
document.getElementById("labelAlign").addEventListener("click", editLabelAlign);
document.getElementById("labelLegend").addEventListener("click", editLabelLegend);
document.getElementById("labelRemoveSingle").addEventListener("click", removeLabel);
function showEditorTips() {
showMainTip();
if (d3.event.target.parentNode.parentNode.id === elSelected.attr("id")) tip("Drag to shift the label");
else if (d3.event.target.parentNode.id === "controlPoints") {
if (d3.event.target.tagName === "circle") tip("Drag to move, click to delete the control point");
if (d3.event.target.tagName === "path") tip("Click to add a control point");
}
}
function selectLabelGroup(text) {
const group = text.parentNode.id;
if (group === "states" || group === "burgLabels") {
document.getElementById("labelGroupShow").style.display = "none";
return;
}
hideGroupSection();
const select = document.getElementById("labelGroupSelect");
select.options.length = 0; // remove all options
labels.selectAll(":scope > g").each(function () {
if (this.id === "states") return;
if (this.id === "burgLabels") return;
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function updateValues(textPath) {
document.getElementById("labelText").value = [...textPath.querySelectorAll("tspan")]
.map(tspan => tspan.textContent)
.join("|");
document.getElementById("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset"));
document.getElementById("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size"));
}
function drawControlPointsAndLine() {
debug.select("#controlPoints").remove();
debug.append("g").attr("id", "controlPoints").attr("transform", elSelected.attr("transform"));
const path = document.getElementById("textPath_" + elSelected.attr("id"));
debug.select("#controlPoints").append("path").attr("d", path.getAttribute("d")).on("click", addInterimControlPoint);
const l = path.getTotalLength();
if (!l) return;
const increment = l / Math.max(Math.ceil(l / 200), 2);
for (let i = 0; i <= l; i += increment) {
addControlPoint(path.getPointAtLength(i));
}
}
function addControlPoint(point) {
debug
.select("#controlPoints")
.append("circle")
.attr("cx", point.x)
.attr("cy", point.y)
.attr("r", 2.5)
.attr("stroke-width", 0.8)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawLabelPath();
}
function redrawLabelPath() {
const path = document.getElementById("textPath_" + elSelected.attr("id"));
lineGen.curve(d3.curveBundle.beta(1));
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
});
const d = round(lineGen(points));
path.setAttribute("d", d);
debug.select("#controlPoints > path").attr("d", d);
}
function clickControlPoint() {
this.remove();
redrawLabelPath();
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const dists = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
const x = +this.getAttribute("cx");
const y = +this.getAttribute("cy");
dists.push((point[0] - x) ** 2 + (point[1] - y) ** 2);
});
let index = dists.length;
if (dists.length > 1) {
const sorted = dists.slice(0).sort((a, b) => a - b);
const closest = dists.indexOf(sorted[0]);
const next = dists.indexOf(sorted[1]);
if (closest <= next) index = closest + 1;
else index = next + 1;
}
const before = ":nth-child(" + (index + 2) + ")";
debug
.select("#controlPoints")
.insert("circle", before)
.attr("cx", point[0])
.attr("cy", point[1])
.attr("r", 2.5)
.attr("stroke-width", 0.8)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
redrawLabelPath();
}
function dragLabel() {
const tr = parseTransform(elSelected.attr("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;
const transform = `translate(${dx + x},${dy + y})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
});
}
function showGroupSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("labelGroupSection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("labelGroupSection").style.display = "none";
document.getElementById("labelGroupInput").style.display = "none";
document.getElementById("labelGroupInput").value = "";
document.getElementById("labelGroupSelect").style.display = "inline-block";
}
function changeGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
function toggleNewGroupInput() {
if (labelGroupInput.style.display === "none") {
labelGroupInput.style.display = "inline-block";
labelGroupInput.focus();
labelGroupSelect.style.display = "none";
} else {
labelGroupInput.style.display = "none";
labelGroupSelect.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name");
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;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
document.getElementById("labelGroupSelect").selectedOptions[0].remove();
document.getElementById("labelGroupSelect").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("labelGroupInput").value = "";
return;
}
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("labels").appendChild(newGroup);
newGroup.id = group;
document.getElementById("labelGroupSelect").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("labelGroupInput").value = "";
}
function removeLabelsGroup() {
const group = elSelected.node().parentNode.id;
const basic = group === "states" || group === "addedLabels";
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
basic ? "all elements in the group" : "the entire label group"
}? <br /><br />Labels to be
removed: ${count}`;
$("#alert").dialog({
resizable: false,
title: "Remove route group",
buttons: {
Remove: function () {
$(this).dialog("close");
$("#labelEditor").dialog("close");
hideGroupSection();
labels
.select("#" + group)
.selectAll("text")
.each(function () {
document.getElementById("textPath_" + this.id).remove();
this.remove();
});
if (!basic) labels.select("#" + group).remove();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function showTextSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("labelTextSection").style.display = "inline-block";
}
function hideTextSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("labelTextSection").style.display = "none";
}
function changeText() {
const input = document.getElementById("labelText").value;
const el = elSelected.select("textPath").node();
const example = d3
.select(elSelected.node().parentNode)
.append("text")
.attr("x", 0)
.attr("x", 0)
.attr("font-size", el.getAttribute("font-size"))
.node();
const lines = input.split("|");
const top = (lines.length - 1) / -2; // y offset
const inner = lines
.map((l, d) => {
example.innerHTML = l;
const left = example.getBBox().width / -2; // x offset
return `<tspan x="${left}px" dy="${d ? 1 : top}em">${l}</tspan>`;
})
.join("");
el.innerHTML = inner;
example.remove();
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
}
function generateRandomName() {
let name = "";
if (elSelected.attr("id").slice(0, 10) === "stateLabel") {
const id = +elSelected.attr("id").slice(10);
const culture = pack.states[id].culture;
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
} else {
const box = elSelected.node().getBBox();
const cell = findCell((box.x + box.width) / 2, (box.y + box.height) / 2);
const culture = pack.cells.culture[cell];
name = Names.getCulture(culture);
}
document.getElementById("labelText").value = name;
changeText();
}
function editGroupStyle() {
const g = elSelected.node().parentNode.id;
editStyle("labels", g);
}
function showSizeSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("labelSizeSection").style.display = "inline-block";
}
function hideSizeSection() {
document.querySelectorAll("#labelEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("labelSizeSection").style.display = "none";
}
function changeStartOffset() {
elSelected.select("textPath").attr("startOffset", this.value + "%");
tip("Label offset: " + this.value + "%");
}
function changeRelativeSize() {
elSelected.select("textPath").attr("font-size", this.value + "%");
tip("Label relative size: " + this.value + "%");
changeText();
}
function editLabelAlign() {
const bbox = elSelected.node().getBBox();
const c = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2];
const path = defs.select("#textPath_" + elSelected.attr("id"));
path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`);
drawControlPointsAndLine();
}
function editLabelLegend() {
const id = elSelected.attr("id");
const name = elSelected.text();
editNotes(id, name);
}
function removeLabel() {
alertMessage.innerHTML = "Are you sure you want to remove the label?";
$("#alert").dialog({
resizable: false,
title: "Remove label",
buttons: {
Remove: function () {
$(this).dialog("close");
defs.select("#textPath_" + elSelected.attr("id")).remove();
elSelected.remove();
$("#labelEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function closeLabelEditor() {
debug.select("#controlPoints").remove();
unselect();
}
}

View file

@ -0,0 +1,261 @@
import {getPackPolygon} from "/src/utils/graphUtils";
import {tip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {rand} from "/src/utils/probabilityUtils";
import {round} from "/src/utils/stringUtils";
import {si} from "/src/utils/unitUtils";
export function editLake() {
if (customization) return;
closeDialogs(".stable");
if (layerIsOn("toggleCells")) toggleCells();
$("#lakeEditor").dialog({
title: "Edit Lake",
resizable: false,
position: {my: "center top+20", at: "top", of: d3.event, collision: "fit"},
close: closeLakesEditor
});
const node = d3.event.target;
debug.append("g").attr("id", "vertices");
elSelected = d3.select(node);
updateLakeValues();
selectLakeGroup(node);
drawLakeVertices();
viewbox.on("touchmove mousemove", null);
if (fmg.modules.editLake) return;
fmg.modules.editLake = true;
// add listeners
document.getElementById("lakeName").addEventListener("input", changeName);
document.getElementById("lakeNameCulture").addEventListener("click", generateNameCulture);
document.getElementById("lakeNameRandom").addEventListener("click", generateNameRandom);
document.getElementById("lakeGroup").addEventListener("change", changeLakeGroup);
document.getElementById("lakeGroupAdd").addEventListener("click", toggleNewGroupInput);
document.getElementById("lakeGroupName").addEventListener("change", createNewGroup);
document.getElementById("lakeGroupRemove").addEventListener("click", removeLakeGroup);
document.getElementById("lakeEditStyle").addEventListener("click", editGroupStyle);
document.getElementById("lakeLegend").addEventListener("click", editLakeLegend);
function getLake() {
const lakeId = +elSelected.attr("data-f");
return pack.features.find(feature => feature.i === lakeId);
}
function updateLakeValues() {
const cells = pack.cells;
const l = getLake();
document.getElementById("lakeName").value = l.name;
document.getElementById("lakeArea").value = si(getArea(l.area)) + " " + getAreaUnit();
const length = d3.polygonLength(l.vertices.map(v => pack.vertices.p[v]));
document.getElementById("lakeShoreLength").value =
si(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const lakeCells = Array.from(cells.i.filter(i => cells.f[i] === l.i));
const heights = lakeCells.map(i => cells.h[i]);
document.getElementById("lakeElevation").value = getHeight(l.height);
document.getElementById("lakeAvarageDepth").value = getHeight(d3.mean(heights), "abs");
document.getElementById("lakeMaxDepth").value = getHeight(d3.min(heights), "abs");
document.getElementById("lakeFlux").value = l.flux;
document.getElementById("lakeEvaporation").value = l.evaporation;
const inlets = l.inlets && l.inlets.map(inlet => pack.rivers.find(river => river.i === inlet)?.name);
const outlet = l.outlet ? pack.rivers.find(river => river.i === l.outlet)?.name : "no";
document.getElementById("lakeInlets").value = inlets ? inlets.length : "no";
document.getElementById("lakeInlets").title = inlets ? inlets.join(", ") : "";
document.getElementById("lakeOutlet").value = outlet;
}
function drawLakeVertices() {
const v = getLake().vertices; // lake outer vertices
const c = [...new Set(v.map(v => pack.vertices.c[v]).flat())];
debug
.select("#vertices")
.selectAll("polygon")
.data(c)
.enter()
.append("polygon")
.attr("points", d => getPackPolygon(d))
.attr("data-c", d => d);
debug
.select("#vertices")
.selectAll("circle")
.data(v)
.enter()
.append("circle")
.attr("cx", d => pack.vertices.p[d][0])
.attr("cy", d => pack.vertices.p[d][1])
.attr("r", 0.4)
.attr("data-v", d => d)
.call(d3.drag().on("drag", dragVertex))
.on("mousemove", () =>
tip("Drag to move the vertex, please use for fine-tuning only. Edit heightmap to change actual cell heights")
);
}
function dragVertex() {
const x = rn(d3.event.x, 2),
y = rn(d3.event.y, 2);
this.setAttribute("cx", x);
this.setAttribute("cy", y);
const v = +this.dataset.v;
pack.vertices.p[v] = [x, y];
debug
.select("#vertices")
.selectAll("polygon")
.attr("points", d => getPackPolygon(d));
redrawLake();
}
function redrawLake() {
lineGen.curve(d3.curveBasisClosed);
const feature = getLake();
const points = feature.vertices.map(v => pack.vertices.p[v]);
const d = round(lineGen(points));
elSelected.attr("d", d);
defs.select("mask#land > path#land_" + feature.i).attr("d", d); // update land mask
feature.area = Math.abs(d3.polygonArea(points));
document.getElementById("lakeArea").value = si(getArea(feature.area)) + " " + getAreaUnit();
}
function changeName() {
getLake().name = this.value;
}
function generateNameCulture() {
const lake = getLake();
lake.name = lakeName.value = Lakes.getName(lake);
}
function generateNameRandom() {
const lake = getLake();
lake.name = lakeName.value = Names.getBase(rand(nameBases.length - 1));
}
function selectLakeGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("lakeGroup");
select.options.length = 0; // remove all options
lakes.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function changeLakeGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
getLake().group = this.value;
}
function toggleNewGroupInput() {
if (lakeGroupName.style.display === "none") {
lakeGroupName.style.display = "inline-block";
lakeGroupName.focus();
lakeGroup.style.display = "none";
} else {
lakeGroupName.style.display = "none";
lakeGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name");
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;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
const basic = ["freshwater", "salt", "sinkhole", "frozen", "lava", "dry"].includes(oldGroup.id);
if (!basic && oldGroup.childElementCount === 1) {
document.getElementById("lakeGroup").selectedOptions[0].remove();
document.getElementById("lakeGroup").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("lakeGroupName").value = "";
return;
}
// create a new group
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("lakes").appendChild(newGroup);
newGroup.id = group;
document.getElementById("lakeGroup").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("lakeGroupName").value = "";
}
function removeLakeGroup() {
const group = elSelected.node().parentNode.id;
if (["freshwater", "salt", "sinkhole", "frozen", "lava", "dry"].includes(group)) {
tip("This is one of the default groups, it cannot be removed", false, "error");
return;
}
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove the group? All lakes of the group (${count}) will be turned into Freshwater`;
$("#alert").dialog({
resizable: false,
title: "Remove lake group",
width: "26em",
buttons: {
Remove: function () {
$(this).dialog("close");
const freshwater = document.getElementById("freshwater");
const groupEl = document.getElementById(group);
while (groupEl.childNodes.length) {
freshwater.appendChild(groupEl.childNodes[0]);
}
groupEl.remove();
document.getElementById("lakeGroup").selectedOptions[0].remove();
document.getElementById("lakeGroup").value = "freshwater";
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function editGroupStyle() {
const g = elSelected.node().parentNode.id;
editStyle("lakes", g);
}
function editLakeLegend() {
const id = elSelected.attr("id");
editNotes(id, getLake().name + " " + lakeGroup.value + " lake");
}
function closeLakesEditor() {
debug.select("#vertices").remove();
unselect();
}
}

1981
src/modules/ui/layers.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,274 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
export function editMarker(markerI) {
if (customization) return;
closeDialogs(".stable");
const [element, marker] = getElement(markerI, d3.event);
if (!marker || !element) return;
elSelected = d3.select(element).raise().call(d3.drag().on("start", dragMarker)).classed("draggable", true);
if (document.getElementById("notesEditor").offsetParent) editNotes(element.id, element.id);
// dom elements
const markerType = document.getElementById("markerType");
const markerIcon = document.getElementById("markerIcon");
const markerIconSelect = document.getElementById("markerIconSelect");
const markerIconSize = document.getElementById("markerIconSize");
const markerIconShiftX = document.getElementById("markerIconShiftX");
const markerIconShiftY = document.getElementById("markerIconShiftY");
const markerSize = document.getElementById("markerSize");
const markerPin = document.getElementById("markerPin");
const markerFill = document.getElementById("markerFill");
const markerStroke = document.getElementById("markerStroke");
const markerNotes = document.getElementById("markerNotes");
const markerLock = document.getElementById("markerLock");
const addMarker = document.getElementById("addMarker");
const markerAdd = document.getElementById("markerAdd");
const markerRemove = document.getElementById("markerRemove");
updateInputs();
$("#markerEditor").dialog({
title: "Edit Marker",
resizable: false,
position: {my: "left top", at: "left+10 top+10", of: "svg", collision: "fit"},
close: closeMarkerEditor
});
const listeners = [
listen(markerType, "change", changeMarkerType),
listen(markerIcon, "input", changeMarkerIcon),
listen(markerIconSelect, "click", selectMarkerIcon),
listen(markerIconSize, "input", changeIconSize),
listen(markerIconShiftX, "input", changeIconShiftX),
listen(markerIconShiftY, "input", changeIconShiftY),
listen(markerSize, "input", changeMarkerSize),
listen(markerPin, "change", changeMarkerPin),
listen(markerFill, "input", changePinFill),
listen(markerStroke, "input", changePinStroke),
listen(markerNotes, "click", editMarkerLegend),
listen(markerLock, "click", toggleMarkerLock),
listen(markerAdd, "click", toggleAddMarker),
listen(markerRemove, "click", confirmMarkerDeletion)
];
function getElement(markerI, event) {
if (event) {
const element = event.target?.closest("svg");
const marker = pack.markers.find(({i}) => Number(element.id.slice(6)) === i);
return [element, marker];
}
const element = document.getElementById(`marker${markerI}`);
const marker = pack.markers.find(({i}) => i === markerI);
return [element, marker];
}
function getSameTypeMarkers() {
const currentType = marker.type;
if (!currentType) return [marker];
return pack.markers.filter(({type}) => type === currentType);
}
function dragMarker() {
const dx = +this.getAttribute("x") - d3.event.x;
const dy = +this.getAttribute("y") - d3.event.y;
d3.event.on("drag", function () {
const {x, y} = d3.event;
this.setAttribute("x", dx + x);
this.setAttribute("y", dy + y);
});
d3.event.on("end", function () {
const {x, y} = d3.event;
this.setAttribute("x", rn(dx + x, 2));
this.setAttribute("y", rn(dy + y, 2));
const size = marker.size || 30;
const zoomSize = Math.max(rn(size / 5 + 24 / scale, 2), 1);
marker.x = rn(x + dx + zoomSize / 2, 1);
marker.y = rn(y + dy + zoomSize, 1);
marker.cell = findCell(marker.x, marker.y);
});
}
function updateInputs() {
const {
icon,
type = "",
size = 30,
dx = 50,
dy = 50,
px = 12,
stroke = "#000000",
fill = "#ffffff",
pin = "bubble",
lock
} = marker;
markerType.value = type;
markerIcon.value = icon;
markerIconSize.value = px;
markerIconShiftX.value = dx;
markerIconShiftY.value = dy;
markerSize.value = size;
markerPin.value = pin;
markerFill.value = fill;
markerStroke.value = stroke;
markerLock.className = lock ? "icon-lock" : "icon-lock-open";
}
function changeMarkerType() {
marker.type = this.value;
}
function changeMarkerIcon() {
const icon = this.value;
getSameTypeMarkers().forEach(marker => {
marker.icon = icon;
redrawIcon(marker);
});
}
function selectMarkerIcon() {
selectIcon(marker.icon, icon => {
markerIcon.value = icon;
getSameTypeMarkers().forEach(marker => {
marker.icon = icon;
redrawIcon(marker);
});
});
}
function changeIconSize() {
const px = +this.value;
getSameTypeMarkers().forEach(marker => {
marker.px = px;
redrawIcon(marker);
});
}
function changeIconShiftX() {
const dx = +this.value;
getSameTypeMarkers().forEach(marker => {
marker.dx = dx;
redrawIcon(marker);
});
}
function changeIconShiftY() {
const dy = +this.value;
getSameTypeMarkers().forEach(marker => {
marker.dy = dy;
redrawIcon(marker);
});
}
function changeMarkerSize() {
const size = +this.value;
const rescale = +markers.attr("rescale");
getSameTypeMarkers().forEach(marker => {
marker.size = size;
const {i, x, y, hidden} = marker;
const el = !hidden && document.getElementById(`marker${i}`);
if (!el) return;
const zoomedSize = rescale ? Math.max(rn(size / 5 + 24 / scale, 2), 1) : size;
el.setAttribute("width", zoomedSize);
el.setAttribute("height", zoomedSize);
el.setAttribute("x", rn(x - zoomedSize / 2, 1));
el.setAttribute("y", rn(y - zoomedSize, 1));
});
}
function changeMarkerPin() {
const pin = this.value;
getSameTypeMarkers().forEach(marker => {
marker.pin = pin;
redrawPin(marker);
});
}
function changePinFill() {
const fill = this.value;
getSameTypeMarkers().forEach(marker => {
marker.fill = fill;
redrawPin(marker);
});
}
function changePinStroke() {
const stroke = this.value;
getSameTypeMarkers().forEach(marker => {
marker.stroke = stroke;
redrawPin(marker);
});
}
function redrawIcon({i, hidden, icon, dx = 50, dy = 50, px = 12}) {
const iconElement = !hidden && document.querySelector(`#marker${i} > text`);
if (iconElement) {
iconElement.innerHTML = icon;
iconElement.setAttribute("x", dx + "%");
iconElement.setAttribute("y", dy + "%");
iconElement.setAttribute("font-size", px + "px");
}
}
function redrawPin({i, hidden, pin = "bubble", fill = "#fff", stroke = "#000"}) {
const pinGroup = !hidden && document.querySelector(`#marker${i} > g`);
if (pinGroup) pinGroup.innerHTML = getPin(pin, fill, stroke);
}
function editMarkerLegend() {
const id = element.id;
editNotes(id, id);
}
function toggleMarkerLock() {
marker.lock = !marker.lock;
markerLock.classList.toggle("icon-lock-open");
markerLock.classList.toggle("icon-lock");
}
function toggleAddMarker() {
markerAdd.classList.toggle("pressed");
addMarker.click();
}
function confirmMarkerDeletion() {
confirmationDialog({
title: "Remove marker",
message: "Are you sure you want to remove this marker? The action cannot be reverted",
confirm: "Remove",
onConfirm: deleteMarker
});
}
function deleteMarker() {
Markers.deleteMarker(marker.i);
element.remove();
$("#markerEditor").dialog("close");
if (document.getElementById("markersOverviewRefresh").offsetParent) markersOverviewRefresh.click();
}
function closeMarkerEditor() {
listeners.forEach(removeListener => removeListener());
unselect();
addMarker.classList.remove("pressed");
markerAdd.classList.remove("pressed");
restoreDefaultEvents();
clearMainTip();
}
}

View file

@ -0,0 +1,202 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {clearMainTip} from "/src/scripts/tooltips";
export function overviewMarkers() {
if (customization) return;
closeDialogs("#markersOverview, .stable");
if (!layerIsOn("toggleMarkers")) toggleMarkers();
const markerGroup = document.getElementById("markers");
const body = document.getElementById("markersBody");
const markersInverPin = document.getElementById("markersInverPin");
const markersInverLock = document.getElementById("markersInverLock");
const markersFooterNumber = document.getElementById("markersFooterNumber");
const markersOverviewRefresh = document.getElementById("markersOverviewRefresh");
const markersAddFromOverview = document.getElementById("markersAddFromOverview");
const markersGenerationConfig = document.getElementById("markersGenerationConfig");
const markersRemoveAll = document.getElementById("markersRemoveAll");
const markersExport = document.getElementById("markersExport");
addLines();
$("#markersOverview").dialog({
title: "Markers Overview",
resizable: false,
width: "fit-content",
close: close,
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
const listeners = [
listen(body, "click", handleLineClick),
listen(markersInverPin, "click", invertPin),
listen(markersInverLock, "click", invertLock),
listen(markersOverviewRefresh, "click", addLines),
listen(markersAddFromOverview, "click", toggleAddMarker),
listen(markersGenerationConfig, "click", configMarkersGeneration),
listen(markersRemoveAll, "click", triggerRemoveAll),
listen(markersExport, "click", exportMarkers)
];
function handleLineClick(ev) {
const el = ev.target;
const i = +el.parentNode.dataset.i;
if (el.classList.contains("icon-pencil")) return openEditor(i);
if (el.classList.contains("icon-dot-circled")) return focusOnMarker(i);
if (el.classList.contains("icon-pin")) return pinMarker(el, i);
if (el.classList.contains("locks")) return toggleLockStatus(el, i);
if (el.classList.contains("icon-trash-empty")) return triggerRemove(i);
}
function addLines() {
const lines = pack.markers
.map(({i, type, icon, pinned, lock}) => {
return /* html */ `<div class="states" data-i=${i} data-type="${type}">
<div data-tip="Marker icon and type" style="width:12em">${icon} ${type}</div>
<span style="padding-right:.1em" data-tip="Edit marker" class="icon-pencil"></span>
<span style="padding-right:.1em" data-tip="Focus on marker position" class="icon-dot-circled pointer"></span>
<span style="padding-right:.1em" data-tip="Pin marker (display only pinned markers)"
class="icon-pin ${pinned ? "" : "inactive"}" pointer"></span>
<span style="padding-right:.1em" data-tip="Toggle element lock. Lock will prevent it from regeneration"
class="locks pointer ${lock ? "icon-lock" : "icon-lock-open inactive"}" ></span>
<span data-tip="Remove marker" class="icon-trash-empty"></span>
</div>`;
})
.join("");
body.innerHTML = lines;
markersFooterNumber.innerText = pack.markers.length;
applySorting(markersHeader);
}
function invertPin() {
let anyPinned = false;
pack.markers.forEach(marker => {
const pinned = !marker.pinned;
if (pinned) {
marker.pinned = true;
anyPinned = true;
} else delete marker.pinned;
});
markerGroup.setAttribute("pinned", anyPinned ? 1 : null);
drawMarkers();
addLines();
}
function invertLock() {
pack.markers = pack.markers.map(marker => ({...marker, lock: !marker.lock}));
addLines();
}
function openEditor(i) {
const marker = pack.markers.find(marker => marker.i === i);
if (!marker) return;
const {x, y} = marker;
zoomTo(x, y, 8, 2000);
editMarker(i);
}
function focusOnMarker(i) {
highlightElement(document.getElementById(`marker${i}`), 2);
}
function pinMarker(el, i) {
const marker = pack.markers.find(marker => marker.i === i);
if (marker.pinned) {
delete marker.pinned;
const anyPinned = pack.markers.some(marker => marker.pinned);
if (!anyPinned) markerGroup.removeAttribute("pinned");
} else {
marker.pinned = true;
markerGroup.setAttribute("pinned", 1);
}
el.classList.toggle("inactive");
drawMarkers();
}
function toggleLockStatus(el, i) {
const marker = pack.markers.find(marker => marker.i === i);
if (marker.lock) {
delete marker.lock;
el.className = "locks pointer icon-lock-open inactive";
} else {
marker.lock = true;
el.className = "locks pointer icon-lock";
}
}
function triggerRemove(i) {
confirmationDialog({
title: "Remove marker",
message: "Are you sure you want to remove this marker? The action cannot be reverted",
confirm: "Remove",
onConfirm: () => removeMarker(i)
});
}
function toggleAddMarker() {
markersAddFromOverview.classList.toggle("pressed");
addMarker.click();
}
function removeMarker(i) {
notes = notes.filter(note => note.id !== `marker${i}`);
pack.markers = pack.markers.filter(marker => marker.i !== i);
document.getElementById(`marker${i}`)?.remove();
addLines();
}
function triggerRemoveAll() {
confirmationDialog({
title: "Remove all markers",
message: "Are you sure you want to remove all non-locked markers? The action cannot be reverted",
confirm: "Remove all",
onConfirm: removeAllMarkers
});
}
function removeAllMarkers() {
pack.markers = pack.markers.filter(({i, lock}) => {
if (lock) return true;
const id = `marker${i}`;
document.getElementById(id)?.remove();
notes = notes.filter(note => note.id !== id);
return false;
});
addLines();
}
function exportMarkers() {
const headers = "Id,Type,Icon,Name,Note,X,Y\n";
const quote = s => '"' + s.replaceAll('"', '""') + '"';
const body = pack.markers.map(marker => {
const {i, type, icon, x, y} = marker;
const id = `marker${i}`;
const note = notes.find(note => note.id === id);
const name = note ? quote(note.name) : "Unknown";
const legend = note ? quote(note.legend) : "";
return [id, type, icon, name, legend, x, y].join(",");
});
const data = headers + body.join("\n");
const fileName = getFileName("Markers") + ".csv";
downloadFile(data, fileName);
}
function close() {
listeners.forEach(removeListener => removeListener());
addMarker.classList.remove("pressed");
markerAdd.classList.remove("pressed");
restoreDefaultEvents();
clearMainTip();
}
}

View file

@ -0,0 +1,489 @@
import {tip} from "/src/scripts/tooltips";
import {wiki} from "/src/utils/linkUtils";
import {rn} from "/src/utils/numberUtils";
import {capitalize} from "/src/utils/stringUtils";
import {si} from "/src/utils/unitUtils";
export function overviewMilitary() {
if (customization) return;
closeDialogs("#militaryOverview, .stable");
if (!layerIsOn("toggleStates")) toggleStates();
if (!layerIsOn("toggleBorders")) toggleBorders();
if (!layerIsOn("toggleMilitary")) toggleMilitary();
const body = document.getElementById("militaryBody");
addLines();
$("#militaryOverview").dialog();
if (fmg.modules.overviewMilitary) return;
fmg.modules.overviewMilitary = true;
updateHeaders();
$("#militaryOverview").dialog({
title: "Military Overview",
resizable: false,
width: "fit-content",
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("militaryOverviewRefresh").addEventListener("click", addLines);
document.getElementById("militaryPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("militaryOptionsButton").addEventListener("click", militaryCustomize);
document.getElementById("militaryRegimentsList").addEventListener("click", () => overviewRegiments(-1));
document.getElementById("militaryOverviewRecalculate").addEventListener("click", militaryRecalculate);
document.getElementById("militaryExport").addEventListener("click", downloadMilitaryData);
document.getElementById("militaryWiki").addEventListener("click", () => wiki("Military-Forces"));
body.addEventListener("change", function (ev) {
const el = ev.target,
line = el.parentNode,
state = +line.dataset.id;
changeAlert(state, line, +el.value);
});
body.addEventListener("click", function (ev) {
const el = ev.target,
line = el.parentNode,
state = +line.dataset.id;
if (el.tagName === "SPAN") overviewRegiments(state);
});
// update military types in header and tooltips
function updateHeaders() {
const header = document.getElementById("militaryHeader");
const units = options.military.length;
header.style.gridTemplateColumns = `8em repeat(${units}, 5.2em) 4em 7em 5em 6em`;
header.querySelectorAll(".removable").forEach(el => el.remove());
const insert = html => document.getElementById("militaryTotal").insertAdjacentHTML("beforebegin", html);
for (const u of options.military) {
const label = capitalize(u.name.replace(/_/g, " "));
insert(
`<div data-tip="State ${u.name} units number. Click to sort" class="sortable removable" data-sortby="${u.name}">${label}&nbsp;</div>`
);
}
header.querySelectorAll(".removable").forEach(function (e) {
e.addEventListener("click", function () {
sortLines(this);
});
});
}
// add line for each state
function addLines() {
body.innerHTML = "";
let lines = "";
const states = pack.states.filter(s => s.i && !s.removed);
for (const s of states) {
const population = rn((s.rural + s.urban * urbanization) * populationRate);
const getForces = u => s.military.reduce((s, r) => s + (r.u[u.name] || 0), 0);
const total = options.military.reduce((s, u) => s + getForces(u) * u.crew, 0);
const rate = (total / population) * 100;
const sortData = options.military.map(u => `data-${u.name}="${getForces(u)}"`).join(" ");
const lineData = options.military
.map(u => `<div data-type="${u.name}" data-tip="State ${u.name} units number">${getForces(u)}</div>`)
.join(" ");
lines += /* html */ `<div
class="states"
data-id=${s.i}
data-state="${s.name}"
${sortData}
data-total="${total}"
data-population="${population}"
data-rate="${rate}"
data-alert="${s.alert}"
>
<fill-box data-tip="${s.fullName}" fill="${s.color}" disabled></fill-box>
<input data-tip="${s.fullName}" style="width:6em" value="${s.name}" readonly />
${lineData}
<div data-type="total" data-tip="Total state military personnel (considering crew)" style="font-weight: bold">${si(
total
)}</div>
<div data-type="population" data-tip="State population">${si(population)}</div>
<div data-type="rate" data-tip="Military personnel rate (% of state population). Depends on war alert">${rn(
rate,
2
)}%</div>
<input
data-tip="War Alert. Editable modifier to military forces number, depends of political situation"
style="width:4.1em"
type="number"
min="0"
step=".01"
value="${rn(s.alert, 2)}"
/>
<span data-tip="Show regiments list" class="icon-list-bullet pointer"></span>
</div>`;
}
body.insertAdjacentHTML("beforeend", lines);
updateFooter();
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => stateHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => stateHighlightOff(ev)));
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(militaryHeader);
}
function changeAlert(state, line, alert) {
const s = pack.states[state];
const dif = s.alert || alert ? alert / s.alert : 0; // modifier
s.alert = line.dataset.alert = alert;
s.military.forEach(r => {
Object.keys(r.u).forEach(u => (r.u[u] = rn(r.u[u] * dif))); // change units value
r.a = d3.sum(Object.values(r.u)); // change total
armies.select(`g>g#regiment${s.i}-${r.i}>text`).text(Military.getTotal(r)); // change icon text
});
const getForces = u => s.military.reduce((s, r) => s + (r.u[u.name] || 0), 0);
options.military.forEach(
u => (line.dataset[u.name] = line.querySelector(`div[data-type='${u.name}']`).innerHTML = getForces(u))
);
const population = rn((s.rural + s.urban * urbanization) * populationRate);
const total = (line.dataset.total = options.military.reduce((s, u) => s + getForces(u) * u.crew, 0));
const rate = (line.dataset.rate = (total / population) * 100);
line.querySelector("div[data-type='total']").innerHTML = si(total);
line.querySelector("div[data-type='rate']").innerHTML = rn(rate, 2) + "%";
updateFooter();
}
function updateFooter() {
const lines = Array.from(body.querySelectorAll(":scope > div"));
const statesNumber = (militaryFooterStates.innerHTML = pack.states.filter(s => s.i && !s.removed).length);
const total = d3.sum(lines.map(el => el.dataset.total));
militaryFooterForcesTotal.innerHTML = si(total);
militaryFooterForces.innerHTML = si(total / statesNumber);
militaryFooterRate.innerHTML = rn(d3.sum(lines.map(el => el.dataset.rate)) / statesNumber, 2) + "%";
militaryFooterAlert.innerHTML = rn(d3.sum(lines.map(el => el.dataset.alert)) / statesNumber, 2);
}
function stateHighlightOn(event) {
const state = +event.target.dataset.id;
if (customization || !state) return;
armies
.select("#army" + state)
.transition()
.duration(2000)
.style("fill", "#ff0000");
if (!layerIsOn("toggleStates")) return;
const d = regions.select("#state" + state).attr("d");
const path = debug
.append("path")
.attr("class", "highlight")
.attr("d", d)
.attr("fill", "none")
.attr("stroke", "red")
.attr("stroke-width", 1)
.attr("opacity", 1)
.attr("filter", "url(#blur1)");
const l = path.node().getTotalLength(),
dur = (l + 5000) / 2;
const i = d3.interpolateString("0," + l, l + "," + l);
path
.transition()
.duration(dur)
.attrTween("stroke-dasharray", function () {
return t => i(t);
});
}
function stateHighlightOff(event) {
debug.selectAll(".highlight").each(function () {
d3.select(this).transition().duration(1000).attr("opacity", 0).remove();
});
const state = +event.target.dataset.id;
armies
.select("#army" + state)
.transition()
.duration(1000)
.style("fill", null);
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const lines = body.querySelectorAll(":scope > div");
const array = Array.from(lines),
cache = [];
const total = function (type) {
if (cache[type]) cache[type];
cache[type] = d3.sum(array.map(el => +el.dataset[type]));
return cache[type];
};
lines.forEach(function (el) {
el.querySelectorAll("div").forEach(function (div) {
const type = div.dataset.type;
if (type === "rate") return;
div.textContent = total(type) ? rn((+el.dataset[type] / total(type)) * 100) + "%" : "0%";
});
});
} else {
body.dataset.type = "absolute";
addLines();
}
}
function militaryCustomize() {
const types = ["melee", "ranged", "mounted", "machinery", "naval", "armored", "aviation", "magical"];
const tableBody = document.getElementById("militaryOptions").querySelector("tbody");
removeUnitLines();
options.military.map(unit => addUnitLine(unit));
$("#militaryOptions").dialog({
title: "Edit Military Units",
resizable: false,
width: "fit-content",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Apply: applyMilitaryOptions,
Add: () =>
addUnitLine({
icon: "🛡️",
name: "custom" + militaryOptionsTable.rows.length,
rural: 0.2,
urban: 0.5,
crew: 1,
power: 1,
type: "melee"
}),
Restore: restoreDefaultUnits,
Cancel: function () {
$(this).dialog("close");
}
},
open: function () {
const buttons = $(this).dialog("widget").find(".ui-dialog-buttonset > button");
buttons[0].addEventListener("mousemove", () =>
tip("Apply military units settings. <span style='color:#cb5858'>All forces will be recalculated!</span>")
);
buttons[1].addEventListener("mousemove", () => tip("Add new military unit to the table"));
buttons[2].addEventListener("mousemove", () => tip("Restore default military units and settings"));
buttons[3].addEventListener("mousemove", () => tip("Close the window without saving the changes"));
}
});
if (fmg.modules.overviewMilitaryCustomize) return;
fmg.modules.overviewMilitaryCustomize = true;
tableBody.addEventListener("click", event => {
const el = event.target;
if (el.tagName !== "BUTTON") return;
const type = el.dataset.type;
if (type === "icon") return selectIcon(el.innerHTML, v => (el.innerHTML = v));
if (type === "biomes") {
const {i, name, color} = biomesData;
const biomesArray = Array(i.length).fill(null);
const biomes = biomesArray.map((_, i) => ({i, name: name[i], color: color[i]}));
return selectLimitation(el, biomes);
}
if (type === "states") return selectLimitation(el, pack.states);
if (type === "cultures") return selectLimitation(el, pack.cultures);
if (type === "religions") return selectLimitation(el, pack.religions);
});
function removeUnitLines() {
tableBody.querySelectorAll("tr").forEach(el => el.remove());
}
function getLimitValue(attr) {
return attr?.join(",") || "";
}
function getLimitText(attr) {
return attr?.length ? "some" : "all";
}
function getLimitTip(attr, data) {
if (!attr || !attr.length) return "";
return attr.map(i => data?.[i]?.name || "").join(", ");
}
function addUnitLine(unit) {
const {type, icon, name, rural, urban, power, crew, separate} = unit;
const row = document.createElement("tr");
const typeOptions = types
.map(t => `<option ${type === t ? "selected" : ""} value="${t}">${t}</option>`)
.join(" ");
const getLimitButton = attr =>
`<button
data-tip="Select allowed ${attr}"
data-type="${attr}"
title="${getLimitTip(unit[attr], pack[attr])}"
data-value="${getLimitValue(unit[attr])}">
${getLimitText(unit[attr])}
</button>`;
row.innerHTML = /* html */ `<td><button data-type="icon" data-tip="Click to select unit icon">${
icon || " "
}</button></td>
<td><input data-tip="Type unit name. If name is changed for existing unit, old unit will be replaced" value="${name}" /></td>
<td>${getLimitButton("biomes")}</td>
<td>${getLimitButton("states")}</td>
<td>${getLimitButton("cultures")}</td>
<td>${getLimitButton("religions")}</td>
<td><input data-tip="Enter conscription percentage for rural population" type="number" min="0" max="100" step=".01" value="${rural}" /></td>
<td><input data-tip="Enter conscription percentage for urban population" type="number" min="0" max="100" step=".01" value="${urban}" /></td>
<td><input data-tip="Enter average number of people in crew (for total personnel calculation)" type="number" min="1" step="1" value="${crew}" /></td>
<td><input data-tip="Enter military power (used for battle simulation)" type="number" min="0" step=".1" value="${power}" /></td>
<td>
<select data-tip="Select unit type to apply special rules on forces recalculation">
${typeOptions}
</select>
</td>
<td data-tip="Check if unit is <b>separate</b> and can be stacked only with the same units">
<input id="${name}Separate" type="checkbox" class="checkbox" ${separate ? "checked" : ""} />
<label for="${name}Separate" class="checkbox-label"></label>
</td>
<td data-tip="Remove the unit">
<span data-tip="Remove unit type" class="icon-trash-empty pointer" onclick="this.parentElement.parentElement.remove();"></span>
</td>`;
tableBody.appendChild(row);
}
function restoreDefaultUnits() {
removeUnitLines();
Military.getDefaultOptions().map(unit => addUnitLine(unit));
}
function selectLimitation(el, data) {
const type = el.dataset.type;
const value = el.dataset.value;
const initial = value ? value.split(",").map(v => +v) : [];
const filtered = data.filter(datum => datum.i && !datum.removed);
const lines = filtered.map(
({i, name, fullName, color}) =>
`<tr data-tip="${name}"><td><span style="color:${color}">⬤</span></td>
<td><input data-i="${i}" id="el${i}" type="checkbox" class="checkbox" ${
!initial.length || initial.includes(i) ? "checked" : ""
} >
<label for="el${i}" class="checkbox-label">${fullName || name}</label>
</td></tr>`
);
alertMessage.innerHTML = /* html */ `<b>Limit unit by ${type}:</b>
<table style="margin-top:.3em">
<tbody>
${lines.join("")}
</tbody>
</table>`;
$("#alert").dialog({
width: "fit-content",
title: `Limit unit`,
buttons: {
Invert: function () {
alertMessage.querySelectorAll("input").forEach(el => (el.checked = !el.checked));
},
Apply: function () {
const inputs = Array.from(alertMessage.querySelectorAll("input"));
const selected = inputs.reduce((acc, input) => {
if (input.checked) acc.push(input.dataset.i);
return acc;
}, []);
if (!selected.length) return tip("Select at least one element", false, "error");
const allAreSelected = selected.length === inputs.length;
el.dataset.value = allAreSelected ? "" : selected.join(",");
el.innerHTML = allAreSelected ? "all" : "some";
el.setAttribute("title", getLimitTip(selected, data));
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function applyMilitaryOptions() {
const unitLines = Array.from(tableBody.querySelectorAll("tr"));
const names = unitLines.map(r => r.querySelector("input").value.replace(/[&\/\\#, +()$~%.'":*?<>{}]/g, "_"));
if (new Set(names).size !== names.length) {
tip("All units should have unique names", false, "error");
return;
}
$("#militaryOptions").dialog("close");
options.military = unitLines.map((r, i) => {
const elements = Array.from(r.querySelectorAll("input, button, select"));
const [icon, name, biomes, states, cultures, religions, rural, urban, crew, power, type, separate] =
elements.map(el => {
const {type, value} = el.dataset || {};
if (type === "icon") return el.innerHTML || "";
if (type) return value ? value.split(",").map(v => parseInt(v)) : null;
if (el.type === "number") return +el.value || 0;
if (el.type === "checkbox") return +el.checked || 0;
return el.value;
});
const unit = {icon, name: names[i], rural, urban, crew, power, type, separate};
if (biomes) unit.biomes = biomes;
if (states) unit.states = states;
if (cultures) unit.cultures = cultures;
if (religions) unit.religions = religions;
return unit;
});
localStorage.setItem("military", JSON.stringify(options.military));
Military.generate();
updateHeaders();
addLines();
}
}
function militaryRecalculate() {
alertMessage.innerHTML =
"Are you sure you want to recalculate military forces for all states?<br>Regiments for all states will be regenerated";
$("#alert").dialog({
resizable: false,
title: "Remove regiment",
buttons: {
Recalculate: function () {
$(this).dialog("close");
Military.generate();
addLines();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function downloadMilitaryData() {
const units = options.military.map(u => u.name);
let data = "Id,State," + units.map(u => capitalize(u)).join(",") + ",Total,Population,Rate,War Alert\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
data += el.dataset.id + ",";
data += el.dataset.state + ",";
data += units.map(u => el.dataset[u]).join(",") + ",";
data += el.dataset.total + ",";
data += el.dataset.population + ",";
data += rn(el.dataset.rate, 2) + "%,";
data += el.dataset.alert + "\n";
});
const name = getFileName("Military") + ".csv";
downloadFile(data, name);
}
}

View file

@ -0,0 +1,277 @@
import {unique} from "/src/utils/arrayUtils";
import {tip} from "/src/scripts/tooltips";
import {openURL} from "/src/utils/linkUtils";
import {rn} from "/src/utils/numberUtils";
export function editNamesbase() {
if (customization) return;
closeDialogs("#namesbaseEditor, .stable");
$("#namesbaseEditor").dialog();
if (fmg.modules.editNamesbase) return;
fmg.modules.editNamesbase = true;
// add listeners
document.getElementById("namesbaseSelect").addEventListener("change", updateInputs);
document.getElementById("namesbaseTextarea").addEventListener("change", updateNamesData);
document.getElementById("namesbaseUpdateExamples").addEventListener("click", updateExamples);
document.getElementById("namesbaseExamples").addEventListener("click", updateExamples);
document.getElementById("namesbaseName").addEventListener("input", updateBaseName);
document.getElementById("namesbaseMin").addEventListener("input", updateBaseMin);
document.getElementById("namesbaseMax").addEventListener("input", updateBaseMax);
document.getElementById("namesbaseDouble").addEventListener("input", updateBaseDublication);
document.getElementById("namesbaseAdd").addEventListener("click", namesbaseAdd);
document.getElementById("namesbaseAnalyze").addEventListener("click", analyzeNamesbase);
document.getElementById("namesbaseDefault").addEventListener("click", namesbaseRestoreDefault);
document.getElementById("namesbaseDownload").addEventListener("click", namesbaseDownload);
const uploader = document.getElementById("namesbaseToLoad");
document.getElementById("namesbaseUpload").addEventListener("click", () => {
uploader.addEventListener(
"change",
function (event) {
uploadFile(event.target, d => namesbaseUpload(d, true));
},
{once: true}
);
uploader.click();
});
document.getElementById("namesbaseUploadExtend").addEventListener("click", () => {
uploader.addEventListener(
"change",
function (event) {
uploadFile(event.target, d => namesbaseUpload(d, false));
},
{once: true}
);
uploader.click();
});
document.getElementById("namesbaseCA").addEventListener("click", () => {
openURL("https://cartographyassets.com/asset-category/specific-assets/azgaars-generator/namebases/");
});
document.getElementById("namesbaseSpeak").addEventListener("click", () => speak(namesbaseExamples.textContent));
createBasesList();
updateInputs();
$("#namesbaseEditor").dialog({
title: "Namesbase Editor",
width: "auto",
position: {my: "center", at: "center", of: "svg"}
});
function createBasesList() {
const select = document.getElementById("namesbaseSelect");
select.innerHTML = "";
nameBases.forEach((b, i) => select.options.add(new Option(b.name, i)));
}
function updateInputs() {
const base = +document.getElementById("namesbaseSelect").value;
if (!nameBases[base]) {
tip(`Namesbase ${base} is not defined`, false, "error");
return;
}
document.getElementById("namesbaseTextarea").value = nameBases[base].b;
document.getElementById("namesbaseName").value = nameBases[base].name;
document.getElementById("namesbaseMin").value = nameBases[base].min;
document.getElementById("namesbaseMax").value = nameBases[base].max;
document.getElementById("namesbaseDouble").value = nameBases[base].d;
updateExamples();
}
function updateExamples() {
const base = +document.getElementById("namesbaseSelect").value;
let examples = "";
for (let i = 0; i < 10; i++) {
const example = Names.getBase(base);
if (example === undefined) {
examples = "Cannot generate examples. Please verify the data";
break;
}
if (i) examples += ", ";
examples += example;
}
document.getElementById("namesbaseExamples").innerHTML = examples;
}
function updateNamesData() {
const base = +document.getElementById("namesbaseSelect").value;
const b = document.getElementById("namesbaseTextarea").value;
if (b.split(",").length < 3) {
tip("The names data provided is too short of incorrect", false, "error");
return;
}
nameBases[base].b = b;
Names.updateChain(base);
}
function updateBaseName() {
const base = +document.getElementById("namesbaseSelect").value;
const select = document.getElementById("namesbaseSelect");
select.options[namesbaseSelect.selectedIndex].innerHTML = this.value;
nameBases[base].name = this.value;
}
function updateBaseMin() {
const base = +document.getElementById("namesbaseSelect").value;
if (+this.value > nameBases[base].max) {
tip("Minimal length cannot be greater than maximal", false, "error");
return;
}
nameBases[base].min = +this.value;
}
function updateBaseMax() {
const base = +document.getElementById("namesbaseSelect").value;
if (+this.value < nameBases[base].min) {
tip("Maximal length should be greater than minimal", false, "error");
return;
}
nameBases[base].max = +this.value;
}
function updateBaseDublication() {
const base = +document.getElementById("namesbaseSelect").value;
nameBases[base].d = this.value;
}
function analyzeNamesbase() {
const namesSourceString = document.getElementById("namesbaseTextarea").value;
const namesArray = namesSourceString.toLowerCase().split(",");
const length = namesArray.length;
if (!namesSourceString || !length) return tip("Names data should not be empty", false, "error");
const chain = Names.calculateChain(namesSourceString);
const variety = rn(d3.mean(Object.values(chain).map(keyValue => keyValue.length)));
const wordsLength = namesArray.map(n => n.length);
const nonLatin = namesSourceString.match(/[^\u0000-\u007f]/g);
const nonBasicLatinChars = nonLatin
? unique(
namesSourceString
.match(/[^\u0000-\u007f]/g)
.join("")
.toLowerCase()
).join("")
: "none";
const geminate = namesArray.map(name => name.match(/[^\w\s]|(.)(?=\1)/g) || []).flat();
const doubled = unique(geminate).filter(
char => geminate.filter(doudledChar => doudledChar === char).length > 3
) || ["none"];
const duplicates = unique(namesArray.filter((e, i, a) => a.indexOf(e) !== i)).join(", ") || "none";
const multiwordRate = d3.mean(namesArray.map(n => +n.includes(" ")));
const getLengthQuality = () => {
if (length < 30)
return "<span data-tip='Namesbase contains < 30 names - not enough to generate reasonable data' style='color:red'>[not enough]</span>";
if (length < 100)
return "<span data-tip='Namesbase contains < 100 names - not enough to generate good names' style='color:darkred'>[low]</span>";
if (length <= 400)
return "<span data-tip='Namesbase contains a reasonable number of samples' style='color:green'>[good]</span>";
return "<span data-tip='Namesbase contains > 400 names. That is too much, try to reduce it to ~300 names' style='color:darkred'>[overmuch]</span>";
};
const getVarietyLevel = () => {
if (variety < 15)
return "<span data-tip='Namesbase average variety < 15 - generated names will be too repetitive' style='color:red'>[low]</span>";
if (variety < 30)
return "<span data-tip='Namesbase average variety < 30 - names can be too repetitive' style='color:orange'>[mean]</span>";
return "<span data-tip='Namesbase variety is good' style='color:green'>[good]</span>";
};
alertMessage.innerHTML = /* html */ `<div style="line-height: 1.6em; max-width: 20em">
<div data-tip="Number of names provided">Namesbase length: ${length} ${getLengthQuality()}</div>
<div data-tip="Average number of generation variants for each key in the chain">Namesbase variety: ${variety} ${getVarietyLevel()}</div>
<hr />
<div data-tip="The shortest name length">Min name length: ${d3.min(wordsLength)}</div>
<div data-tip="The longest name length">Max name length: ${d3.max(wordsLength)}</div>
<div data-tip="Average name length">Mean name length: ${rn(d3.mean(wordsLength), 1)}</div>
<div data-tip="Common name length">Median name length: ${d3.median(wordsLength)}</div>
<hr />
<div data-tip="Characters outside of Basic Latin have bad font support">Non-basic chars: ${nonBasicLatinChars}</div>
<div data-tip="Characters that are frequently (more than 3 times) doubled">Doubled chars: ${doubled.join(
""
)}</div>
<div data-tip="Names used more than one time">Duplicates: ${duplicates}</div>
<div data-tip="Percentage of names containing space character">Multi-word names: ${rn(
multiwordRate * 100,
2
)}%</div>
</div>`;
$("#alert").dialog({
resizable: false,
title: "Data Analysis",
position: {my: "left top-30", at: "right+10 top", of: "#namesbaseEditor"},
buttons: {
OK: function () {
$(this).dialog("close");
}
}
});
}
function namesbaseAdd() {
const base = nameBases.length;
const b =
"This,is,an,example,of,name,base,showing,correct,format,It,should,have,at,least,one,hundred,names,separated,with,comma";
nameBases.push({name: "Base" + base, min: 5, max: 12, d: "", m: 0, b});
document.getElementById("namesbaseSelect").add(new Option("Base" + base, base));
document.getElementById("namesbaseSelect").value = base;
document.getElementById("namesbaseTextarea").value = b;
document.getElementById("namesbaseName").value = "Base" + base;
document.getElementById("namesbaseMin").value = 5;
document.getElementById("namesbaseMax").value = 12;
document.getElementById("namesbaseDouble").value = "";
document.getElementById("namesbaseExamples").innerHTML = "Please provide names data";
}
function namesbaseRestoreDefault() {
alertMessage.innerHTML = /* html */ `Are you sure you want to restore default namesbase?`;
$("#alert").dialog({
resizable: false,
title: "Restore default data",
buttons: {
Restore: function () {
$(this).dialog("close");
Names.clearChains();
nameBases = Names.getNameBases();
createBasesList();
updateInputs();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function namesbaseDownload() {
const data = nameBases.map((b, i) => `${b.name}|${b.min}|${b.max}|${b.d}|${b.m}|${b.b}`).join("\r\n");
const name = getFileName("Namesbase") + ".txt";
downloadFile(data, name);
}
function namesbaseUpload(dataLoaded, override = true) {
const data = dataLoaded.split("\r\n");
if (!data || !data[0]) {
tip("Cannot load a namesbase. Please check the data format", false, "error");
return;
}
Names.clearChains();
if (override) nameBases = [];
data.forEach(d => {
const e = d.split("|");
nameBases.push({name: e[0], min: e[1], max: e[2], d: e[3], m: e[4], b: e[5]});
});
createBasesList();
updateInputs();
}
}

View file

@ -0,0 +1,188 @@
import {tip} from "/src/scripts/tooltips";
export function editNotes(id, name) {
// elements
const notesLegend = document.getElementById("notesLegend");
const notesName = document.getElementById("notesName");
const notesSelect = document.getElementById("notesSelect");
const notesPin = document.getElementById("notesPin");
// update list of objects
notesSelect.options.length = 0;
for (const note of notes) {
notesSelect.options.add(new Option(note.id, note.id));
}
// update pin notes icon
const notesArePinned = options.pinNotes;
if (notesArePinned) notesPin.classList.add("pressed");
else notesPin.classList.remove("pressed");
// select an object
if (notes.length || id) {
if (!id) id = notes[0].id;
let note = notes.find(note => note.id === id);
if (note === undefined) {
if (!name) name = id;
note = {id, name, legend: ""};
notes.push(note);
notesSelect.options.add(new Option(id, id));
}
notesSelect.value = id;
notesName.value = note.name;
notesLegend.innerHTML = note.legend;
initEditor();
updateNotesBox(note);
} else {
// if notes array is empty
notesName.value = "";
notesLegend.innerHTML = "No notes added. Click on an element (e.g. label or marker) and add a free text note";
}
$("#notesEditor").dialog({
title: "Notes Editor",
width: "minmax(80vw, 540px)",
height: window.innerHeight * 0.75,
position: {my: "center", at: "center", of: "svg"},
close: removeEditor
});
$("[aria-describedby='notesEditor']").css("top", "10vh");
if (modules.editNotes) return;
modules.editNotes = true;
// add listeners
document.getElementById("notesSelect").addEventListener("change", changeElement);
document.getElementById("notesName").addEventListener("input", changeName);
document.getElementById("notesLegend").addEventListener("blur", updateLegend);
document.getElementById("notesPin").addEventListener("click", toggleNotesPin);
document.getElementById("notesFocus").addEventListener("click", validateHighlightElement);
document.getElementById("notesDownload").addEventListener("click", downloadLegends);
document.getElementById("notesUpload").addEventListener("click", () => legendsToLoad.click());
document.getElementById("legendsToLoad").addEventListener("change", function () {
uploadFile(this, uploadLegends);
});
document.getElementById("notesRemove").addEventListener("click", triggerNotesRemove);
async function initEditor() {
if (!window.tinymce) {
const url = "https://cdn.tiny.cloud/1/4i6a79ymt2y0cagke174jp3meoi28vyecrch12e5puyw3p9a/tinymce/5/tinymce.min.js";
try {
await import(/* @vite-ignore */ url);
} catch (error) {
// error may be caused by failed request being cached, try again with random hash
try {
const hash = Math.random().toString(36).substring(2, 15);
await import(/* @vite-ignore */ `${url}#${hash}`);
} catch (error) {
console.error(error);
}
}
}
if (window.tinymce) {
tinymce.init({
selector: "#notesLegend",
height: "90%",
menubar: false,
plugins: `autolink lists link charmap print code fullscreen image link media table paste hr wordcount`,
toolbar: `code | undo redo | removeformat | bold italic strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table | fontselect fontsizeselect | blockquote hr charmap | print fullscreen`,
media_alt_source: false,
media_poster: false,
browser_spellcheck: true,
contextmenu: false,
setup: editor => {
editor.on("Change", updateLegend);
}
});
}
}
function updateLegend() {
const note = notes.find(note => note.id === notesSelect.value);
if (!note) return tip("Note element is not found", true, "error", 4000);
const isTinyEditorActive = window.tinymce?.activeEditor;
note.legend = isTinyEditorActive ? tinymce.activeEditor.getContent() : notesLegend.innerHTML;
updateNotesBox(note);
}
function updateNotesBox(note) {
document.getElementById("notesHeader").innerHTML = note.name;
document.getElementById("notesBody").innerHTML = note.legend;
}
function changeElement() {
const note = notes.find(note => note.id === this.value);
if (!note) return tip("Note element is not found", true, "error", 4000);
notesName.value = note.name;
notesLegend.innerHTML = note.legend;
updateNotesBox(note);
if (window.tinymce) tinymce.activeEditor.setContent(note.legend);
}
function changeName() {
const note = notes.find(note => note.id === notesSelect.value);
if (!note) return tip("Note element is not found", true, "error", 4000);
note.name = this.value;
}
function validateHighlightElement() {
const element = document.getElementById(notesSelect.value);
if (element) return highlightElement(element, 3);
confirmationDialog({
title: "Element not found",
message: "Note element is not found. Would you like to remove the note?",
confirm: "Remove",
cancel: "Keep",
onConfirm: removeLegend
});
}
function downloadLegends() {
const notesData = JSON.stringify(notes);
const name = getFileName("Notes") + ".txt";
downloadFile(notesData, name);
}
function uploadLegends(dataLoaded) {
if (!dataLoaded) return tip("Cannot load the file. Please check the data format", false, "error");
notes = JSON.parse(dataLoaded);
notesSelect.options.length = 0;
editNotes(notes[0].id, notes[0].name);
}
function triggerNotesRemove() {
confirmationDialog({
title: "Remove note",
message: "Are you sure you want to remove the selected note? There is no way to undo this action",
confirm: "Remove",
onConfirm: removeLegend
});
}
function removeLegend() {
const index = notes.findIndex(n => n.id === notesSelect.value);
notes.splice(index, 1);
notesSelect.options.length = 0;
if (!notes.length) {
$("#notesEditor").dialog("close");
return;
}
editNotes(notes[0].id, notes[0].name);
}
function toggleNotesPin() {
options.pinNotes = !options.pinNotes;
this.classList.toggle("pressed");
}
function removeEditor() {
if (window.tinymce) tinymce.remove();
}
}

1058
src/modules/ui/options.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,454 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {capitalize} from "/src/utils/stringUtils";
export function editRegiment(selector) {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleMilitary")) toggleMilitary();
armies.selectAll(":scope > g").classed("draggable", true);
armies.selectAll(":scope > g > g").call(d3.drag().on("drag", dragRegiment));
elSelected = selector ? document.querySelector(selector) : d3.event.target.parentElement; // select g element
if (!pack.states[elSelected.dataset.state]) return;
if (!regiment()) return;
updateRegimentData(regiment());
drawBase();
$("#regimentEditor").dialog({
title: "Edit Regiment",
resizable: false,
close: closeEditor,
position: {my: "left top", at: "left+10 top+10", of: "#map"}
});
if (fmg.modules.editRegiment) return;
fmg.modules.editRegiment = true;
// add listeners
document.getElementById("regimentNameRestore").addEventListener("click", restoreName);
document.getElementById("regimentType").addEventListener("click", changeType);
document.getElementById("regimentName").addEventListener("change", changeName);
document.getElementById("regimentEmblem").addEventListener("input", changeEmblem);
document.getElementById("regimentEmblemSelect").addEventListener("click", selectEmblem);
document.getElementById("regimentAttack").addEventListener("click", toggleAttack);
document.getElementById("regimentRegenerateLegend").addEventListener("click", regenerateLegend);
document.getElementById("regimentLegend").addEventListener("click", editLegend);
document.getElementById("regimentSplit").addEventListener("click", splitRegiment);
document.getElementById("regimentAdd").addEventListener("click", toggleAdd);
document.getElementById("regimentAttach").addEventListener("click", toggleAttach);
document.getElementById("regimentRemove").addEventListener("click", removeRegiment);
// get regiment data element
function regiment() {
return pack.states[elSelected.dataset.state].military.find(r => r.i == elSelected.dataset.id);
}
function updateRegimentData(regiment) {
document.getElementById("regimentType").className = regiment.n ? "icon-anchor" : "icon-users";
document.getElementById("regimentName").value = regiment.name;
document.getElementById("regimentEmblem").value = regiment.icon;
const composition = document.getElementById("regimentComposition");
composition.innerHTML = options.military
.map(u => {
return `<div data-tip="${capitalize(u.name)} number. Input to change">
<div class="label">${capitalize(u.name)}:</div>
<input data-u="${u.name}" type="number" min=0 step=1 value="${regiment.u[u.name] || 0}">
<i>${u.type}</i></div>`;
})
.join("");
composition.querySelectorAll("input").forEach(el => el.addEventListener("change", changeUnit));
}
function drawBase() {
const reg = regiment();
const clr = pack.states[elSelected.dataset.state].color;
const base = viewbox
.insert("g", "g#armies")
.attr("id", "regimentBase")
.attr("stroke-width", 0.3)
.attr("stroke", "#000")
.attr("cursor", "move");
base
.on("mouseenter", () => {
tip("Regiment base. Drag to re-base the regiment", true);
})
.on("mouseleave", () => {
tip("", true);
});
base
.append("line")
.attr("x1", reg.bx)
.attr("y1", reg.by)
.attr("x2", reg.x)
.attr("y2", reg.y)
.attr("class", "regimentDragLine");
base
.append("circle")
.attr("cx", reg.bx)
.attr("cy", reg.by)
.attr("r", 2)
.attr("fill", clr)
.call(d3.drag().on("drag", dragBase));
}
function changeType() {
const reg = regiment();
reg.n = +!reg.n;
document.getElementById("regimentType").className = reg.n ? "icon-anchor" : "icon-users";
const size = +armies.attr("box-size");
const baseRect = elSelected.querySelectorAll("rect")[0];
const iconRect = elSelected.querySelectorAll("rect")[1];
const icon = elSelected.querySelector(".regimentIcon");
const x = reg.n ? reg.x - size * 2 : reg.x - size * 3;
baseRect.setAttribute("x", x);
baseRect.setAttribute("width", reg.n ? size * 4 : size * 6);
iconRect.setAttribute("x", x - size * 2);
icon.setAttribute("x", x - size);
elSelected.querySelector("text").innerHTML = Military.getTotal(reg);
}
function changeName() {
elSelected.dataset.name = regiment().name = this.value;
}
function restoreName() {
const reg = regiment(),
regs = pack.states[elSelected.dataset.state].military;
const name = Military.getName(reg, regs);
elSelected.dataset.name = reg.name = document.getElementById("regimentName").value = name;
}
function selectEmblem() {
selectIcon(regimentEmblem.value, v => {
regimentEmblem.value = v;
changeEmblem();
});
}
function changeEmblem() {
const emblem = document.getElementById("regimentEmblem").value;
regiment().icon = elSelected.querySelector(".regimentIcon").innerHTML = emblem;
}
function changeUnit() {
const u = this.dataset.u;
const reg = regiment();
reg.u[u] = +this.value || 0;
reg.a = d3.sum(Object.values(reg.u));
elSelected.querySelector("text").innerHTML = Military.getTotal(reg);
if (militaryOverviewRefresh.offsetParent) militaryOverviewRefresh.click();
if (regimentsOverviewRefresh.offsetParent) regimentsOverviewRefresh.click();
}
function splitRegiment() {
const reg = regiment(),
u1 = reg.u;
const state = +elSelected.dataset.state,
military = pack.states[state].military;
const i = last(military).i + 1,
u2 = Object.assign({}, u1); // u clone
Object.keys(u2).forEach(u => (u2[u] = Math.floor(u2[u] / 2))); // halved new reg
const a = d3.sum(Object.values(u2)); // new reg total
if (!a) {
tip("Not enough forces to split", false, "error");
return;
} // nothing to add
// update old regiment
Object.keys(u1).forEach(u => (u1[u] = Math.ceil(u1[u] / 2))); // halved old reg
reg.a = d3.sum(Object.values(u1)); // old reg total
regimentComposition.querySelectorAll("input").forEach(el => (el.value = reg.u[el.dataset.u] || 0));
elSelected.querySelector("text").innerHTML = Military.getTotal(reg);
// create new regiment
const shift = +armies.attr("box-size") * 2;
const y = function (x, y) {
do {
y += shift;
} while (military.find(r => r.x === x && r.y === y));
return y;
};
const newReg = {
a,
cell: reg.cell,
i,
n: reg.n,
u: u2,
x: reg.x,
y: y(reg.x, reg.y),
bx: reg.bx,
by: reg.by,
state,
icon: reg.icon
};
newReg.name = Military.getName(newReg, military);
military.push(newReg);
Military.generateNote(newReg, pack.states[state]); // add legend
Military.drawRegiment(newReg, state); // draw new reg below
if (regimentsOverviewRefresh.offsetParent) regimentsOverviewRefresh.click();
}
function toggleAdd() {
document.getElementById("regimentAdd").classList.toggle("pressed");
if (document.getElementById("regimentAdd").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", addRegimentOnClick);
tip("Click on map to create new regiment or fleet", true);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
}
}
function addRegimentOnClick() {
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
const x = pack.cells.p[cell][0],
y = pack.cells.p[cell][1];
const state = +elSelected.dataset.state,
military = pack.states[state].military;
const i = military.length ? last(military).i + 1 : 0;
const n = +(pack.cells.h[cell] < 20); // naval or land
const reg = {a: 0, cell, i, n, u: {}, x, y, bx: x, by: y, state, icon: "🛡️"};
reg.name = Military.getName(reg, military);
military.push(reg);
Military.generateNote(reg, pack.states[state]); // add legend
Military.drawRegiment(reg, state);
if (regimentsOverviewRefresh.offsetParent) regimentsOverviewRefresh.click();
toggleAdd();
}
function toggleAttack() {
document.getElementById("regimentAttack").classList.toggle("pressed");
if (document.getElementById("regimentAttack").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", attackRegimentOnClick);
tip("Click on another regiment to initiate battle", true);
armies.selectAll(":scope > g").classed("draggable", false);
} else {
clearMainTip();
armies.selectAll(":scope > g").classed("draggable", true);
viewbox.on("click", clicked).style("cursor", "default");
}
}
function attackRegimentOnClick() {
const target = d3.event.target,
regSelected = target.parentElement,
army = regSelected.parentElement;
const oldState = +elSelected.dataset.state,
newState = +regSelected.dataset.state;
if (army.parentElement.id !== "armies") {
tip("Please click on a regiment to attack", false, "error");
return;
}
if (regSelected === elSelected) {
tip("Regiment cannot attack itself", false, "error");
return;
}
if (oldState === newState) {
tip("Cannot attack fraternal regiment", false, "error");
return;
}
const attacker = regiment();
const defender = pack.states[regSelected.dataset.state].military.find(r => r.i == regSelected.dataset.id);
if (!attacker.a || !defender.a) {
tip("Regiment has no troops to battle", false, "error");
return;
}
// save initial position to temp attribute
(attacker.px = attacker.x), (attacker.py = attacker.y);
(defender.px = defender.x), (defender.py = defender.y);
// move attacker to defender
Military.moveRegiment(attacker, defender.x, defender.y - 8);
// draw battle icon
const attack = d3
.transition()
.delay(300)
.duration(700)
.ease(d3.easeSinInOut)
.on("end", () => new Battle(attacker, defender));
svg
.append("text")
.attr("x", window.innerWidth / 2)
.attr("y", window.innerHeight / 2)
.text("⚔️")
.attr("font-size", 0)
.attr("opacity", 1)
.style("dominant-baseline", "central")
.style("text-anchor", "middle")
.transition(attack)
.attr("font-size", 1000)
.attr("opacity", 0.2)
.remove();
clearMainTip();
$("#regimentEditor").dialog("close");
}
function toggleAttach() {
document.getElementById("regimentAttach").classList.toggle("pressed");
if (document.getElementById("regimentAttach").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", attachRegimentOnClick);
tip("Click on another regiment to unite both regiments. The current regiment will be removed", true);
armies.selectAll(":scope > g").classed("draggable", false);
} else {
clearMainTip();
armies.selectAll(":scope > g").classed("draggable", true);
viewbox.on("click", clicked).style("cursor", "default");
}
}
function attachRegimentOnClick() {
const target = d3.event.target,
regSelected = target.parentElement,
army = regSelected.parentElement;
const oldState = +elSelected.dataset.state,
newState = +regSelected.dataset.state;
if (army.parentElement.id !== "armies") {
tip("Please click on a regiment", false, "error");
return;
}
if (regSelected === elSelected) {
tip("Cannot attach regiment to itself. Please click on another regiment", false, "error");
return;
}
const reg = regiment(); // reg to be attached
const sel = pack.states[newState].military.find(r => r.i == regSelected.dataset.id); // reg to attach to
for (const unit of options.military) {
const u = unit.name;
if (reg.u[u]) sel.u[u] ? (sel.u[u] += reg.u[u]) : (sel.u[u] = reg.u[u]);
}
sel.a = d3.sum(Object.values(sel.u)); // reg total
regSelected.querySelector("text").innerHTML = Military.getTotal(sel); // update selected reg total text
// remove attached regiment
const military = pack.states[oldState].military;
military.splice(military.indexOf(reg), 1);
const index = notes.findIndex(n => n.id === elSelected.id);
if (index != -1) notes.splice(index, 1);
elSelected.remove();
if (regimentsOverviewRefresh.offsetParent) regimentsOverviewRefresh.click();
$("#regimentEditor").dialog("close");
editRegiment("#" + regSelected.id);
}
function regenerateLegend() {
const index = notes.findIndex(n => n.id === elSelected.id);
if (index != -1) notes.splice(index, 1);
const s = pack.states[elSelected.dataset.state];
Military.generateNote(regiment(), s);
}
function editLegend() {
editNotes(elSelected.id, regiment().name);
}
function removeRegiment() {
alertMessage.innerHTML = "Are you sure you want to remove the regiment?";
$("#alert").dialog({
resizable: false,
title: "Remove regiment",
buttons: {
Remove: function () {
$(this).dialog("close");
const military = pack.states[elSelected.dataset.state].military;
const regIndex = military.indexOf(regiment());
if (regIndex === -1) return;
military.splice(regIndex, 1);
const index = notes.findIndex(n => n.id === elSelected.id);
if (index != -1) notes.splice(index, 1);
elSelected.remove();
if (militaryOverviewRefresh.offsetParent) militaryOverviewRefresh.click();
if (regimentsOverviewRefresh.offsetParent) regimentsOverviewRefresh.click();
$("#regimentEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function dragRegiment() {
d3.select(this).raise();
d3.select(this.parentNode).raise();
const reg = pack.states[this.dataset.state].military.find(r => r.i == this.dataset.id);
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = x => rn(x - w / 2, 2);
const y1 = y => rn(y - size, 2);
const baseRect = this.querySelector("rect");
const text = this.querySelector("text");
const iconRect = this.querySelectorAll("rect")[1];
const icon = this.querySelector(".regimentIcon");
const self = elSelected === this;
const baseLine = viewbox.select("g#regimentBase > line");
d3.event.on("drag", function () {
const x = (reg.x = d3.event.x),
y = (reg.y = d3.event.y);
baseRect.setAttribute("x", x1(x));
baseRect.setAttribute("y", y1(y));
text.setAttribute("x", x);
text.setAttribute("y", y);
iconRect.setAttribute("x", x1(x) - h);
iconRect.setAttribute("y", y1(y));
icon.setAttribute("x", x1(x) - size);
icon.setAttribute("y", y);
if (self) baseLine.attr("x2", x).attr("y2", y);
});
}
function dragBase() {
const baseLine = viewbox.select("g#regimentBase > line");
const reg = regiment();
d3.event.on("drag", function () {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
baseLine.attr("x1", d3.event.x).attr("y1", d3.event.y);
});
d3.event.on("end", function () {
reg.bx = d3.event.x;
reg.by = d3.event.y;
});
}
function closeEditor() {
armies.selectAll(":scope > g").classed("draggable", false);
armies.selectAll("g>g").call(d3.drag().on("drag", null));
viewbox.selectAll("g#regimentBase").remove();
document.getElementById("regimentAdd").classList.remove("pressed");
document.getElementById("regimentAttack").classList.remove("pressed");
document.getElementById("regimentAttach").classList.remove("pressed");
restoreDefaultEvents();
elSelected = null;
}
}

View file

@ -0,0 +1,210 @@
import {findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {capitalize} from "/src/utils/stringUtils";
import {si} from "/src/utils/unitUtils";
export function overviewRegiments(state) {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleMilitary")) toggleMilitary();
const body = document.getElementById("regimentsBody");
updateFilter(state);
addLines();
$("#regimentsOverview").dialog();
if (fmg.modules.overviewRegiments) return;
fmg.modules.overviewRegiments = true;
updateHeaders();
$("#regimentsOverview").dialog({
title: "Regiments Overview",
resizable: false,
width: "fit-content",
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("regimentsOverviewRefresh").addEventListener("click", addLines);
document.getElementById("regimentsPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("regimentsAddNew").addEventListener("click", toggleAdd);
document.getElementById("regimentsExport").addEventListener("click", downloadRegimentsData);
document.getElementById("regimentsFilter").addEventListener("change", addLines);
// update military types in header and tooltips
function updateHeaders() {
const header = document.getElementById("regimentsHeader");
const units = options.military.length;
header.style.gridTemplateColumns = `9em 13em repeat(${units}, 5.2em) 7em`;
header.querySelectorAll(".removable").forEach(el => el.remove());
const insert = html => document.getElementById("regimentsTotal").insertAdjacentHTML("beforebegin", html);
for (const u of options.military) {
const label = capitalize(u.name.replace(/_/g, " "));
insert(
`<div data-tip="Regiment ${u.name} units number. Click to sort" class="sortable removable" data-sortby="${u.name}">${label}&nbsp;</div>`
);
}
header.querySelectorAll(".removable").forEach(function (e) {
e.addEventListener("click", function () {
sortLines(this);
});
});
}
// add line for each state
function addLines() {
const state = +regimentsFilter.value;
body.innerHTML = "";
let lines = "";
const regiments = [];
for (const s of pack.states) {
if (!s.i || s.removed || !s.military.length) continue;
if (state !== -1 && s.i !== state) continue; // specific state is selected
for (const r of s.military) {
const sortData = options.military.map(u => `data-${u.name}=${r.u[u.name] || 0}`).join(" ");
const lineData = options.military
.map(
u => `<div data-type="${u.name}" data-tip="${capitalize(u.name)} units number">${r.u[u.name] || 0}</div>`
)
.join(" ");
lines += /* html */ `<div class="states" data-id=${r.i} data-s="${s.i}" data-state="${s.name}" data-name="${r.name}" ${sortData} data-total="${r.a}">
<fill-box data-tip="${s.fullName}" fill="${s.color}" disabled></fill-box>
<input data-tip="${s.fullName}" style="width:6em" value="${s.name}" readonly />
<span data-tip="Regiment's emblem" style="width:1em">${r.icon}</span>
<input data-tip="Regiment's name" style="width:13em" value="${r.name}" readonly />
${lineData}
<div data-type="total" data-tip="Total military personnel (not considering crew)" style="font-weight: bold">${r.a}</div>
<span data-tip="Edit regiment" onclick="editRegiment('#regiment${s.i}-${r.i}')" class="icon-pencil pointer"></span>
</div>`;
regiments.push(r);
}
}
lines += /* html */ `<div id="regimentsTotalLine" class="totalLine" data-tip="Total of all displayed regiments">
<div style="width: 21em; margin-left: 1em">Regiments: ${regiments.length}</div>
${options.military
.map(u => `<div style="width:5em">${si(d3.sum(regiments.map(r => r.u[u.name] || 0)))}</div>`)
.join(" ")}
<div style="width:5em">${si(d3.sum(regiments.map(r => r.a)))}</div>
</div>`;
body.insertAdjacentHTML("beforeend", lines);
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(regimentsHeader);
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => regimentHighlightOn(ev)));
body
.querySelectorAll("div.states")
.forEach(el => el.addEventListener("mouseleave", ev => regimentHighlightOff(ev)));
}
function updateFilter(state) {
const filter = document.getElementById("regimentsFilter");
filter.options.length = 0; // remove all options
filter.options.add(new Option(`all`, -1, false, state === -1));
const statesSorted = pack.states.filter(s => s.i && !s.removed).sort((a, b) => (a.name > b.name ? 1 : -1));
statesSorted.forEach(s => filter.options.add(new Option(s.name, s.i, false, s.i == state)));
}
function regimentHighlightOn(event) {
const state = +event.target.dataset.s;
const id = +event.target.dataset.id;
if (customization || !state) return;
armies.select(`g > g#regiment${state}-${id}`).transition().duration(2000).style("fill", "#ff0000");
}
function regimentHighlightOff(event) {
const state = +event.target.dataset.s;
const id = +event.target.dataset.id;
armies.select(`g > g#regiment${state}-${id}`).transition().duration(1000).style("fill", null);
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const lines = body.querySelectorAll(":scope > div:not(.totalLine)");
const array = Array.from(lines),
cache = [];
const total = function (type) {
if (cache[type]) cache[type];
cache[type] = d3.sum(array.map(el => +el.dataset[type]));
return cache[type];
};
lines.forEach(function (el) {
el.querySelectorAll("div").forEach(function (div) {
const type = div.dataset.type;
if (type === "rate") return;
div.textContent = total(type) ? rn((+el.dataset[type] / total(type)) * 100) + "%" : "0%";
});
});
} else {
body.dataset.type = "absolute";
addLines();
}
}
function toggleAdd() {
document.getElementById("regimentsAddNew").classList.toggle("pressed");
if (document.getElementById("regimentsAddNew").classList.contains("pressed")) {
viewbox.style("cursor", "crosshair").on("click", addRegimentOnClick);
tip("Click on map to create new regiment or fleet", true);
if (regimentAdd.offsetParent) regimentAdd.classList.add("pressed");
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
addLines();
if (regimentAdd.offsetParent) regimentAdd.classList.remove("pressed");
}
}
function addRegimentOnClick() {
const state = +regimentsFilter.value;
if (state === -1) {
tip("Please select state from the list", false, "error");
return;
}
const point = d3.mouse(this);
const cell = findCell(point[0], point[1]);
const x = pack.cells.p[cell][0],
y = pack.cells.p[cell][1];
const military = pack.states[state].military;
const i = military.length ? last(military).i + 1 : 0;
const n = +(pack.cells.h[cell] < 20); // naval or land
const reg = {a: 0, cell, i, n, u: {}, x, y, bx: x, by: y, state, icon: "🛡️"};
reg.name = Military.getName(reg, military);
military.push(reg);
Military.generateNote(reg, pack.states[state]); // add legend
Military.drawRegiment(reg, state);
toggleAdd();
}
function downloadRegimentsData() {
const units = options.military.map(u => u.name);
let data = "State,Id,Name," + units.map(u => capitalize(u)).join(",") + ",Total\n"; // headers
body.querySelectorAll(":scope > div:not(.totalLine)").forEach(function (el) {
data += el.dataset.state + ",";
data += el.dataset.id + ",";
data += el.dataset.name + ",";
data += units.map(u => el.dataset[u]).join(",") + ",";
data += el.dataset.total + "\n";
});
const name = getFileName("Regiments") + ".csv";
downloadFile(data, name);
}
}

View file

@ -0,0 +1,294 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
export function editReliefIcon() {
if (customization) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRelief")) toggleRelief();
terrain.selectAll("use").call(d3.drag().on("drag", dragReliefIcon)).classed("draggable", true);
elSelected = d3.select(d3.event.target);
restoreEditMode();
updateReliefIconSelected();
updateReliefSizeInput();
$("#reliefEditor").dialog({
title: "Edit Relief Icons",
resizable: false,
width: "27em",
position: {my: "left top", at: "left+10 top+10", of: "#map"},
close: closeReliefEditor
});
if (fmg.modules.editReliefIcon) return;
fmg.modules.editReliefIcon = true;
// add listeners
document.getElementById("reliefIndividual").addEventListener("click", enterIndividualMode);
document.getElementById("reliefBulkAdd").addEventListener("click", enterBulkAddMode);
document.getElementById("reliefBulkRemove").addEventListener("click", enterBulkRemoveMode);
document.getElementById("reliefSize").addEventListener("input", changeIconSize);
document.getElementById("reliefSizeNumber").addEventListener("input", changeIconSize);
document.getElementById("reliefEditorSet").addEventListener("change", changeIconsSet);
reliefIconsDiv.querySelectorAll("svg").forEach(el => el.addEventListener("click", changeIcon));
document.getElementById("reliefEditStyle").addEventListener("click", () => editStyle("terrain"));
document.getElementById("reliefCopy").addEventListener("click", copyIcon);
document.getElementById("reliefMoveFront").addEventListener("click", () => elSelected.raise());
document.getElementById("reliefMoveBack").addEventListener("click", () => elSelected.lower());
document.getElementById("reliefRemove").addEventListener("click", removeIcon);
function dragReliefIcon() {
const dx = +this.getAttribute("x") - d3.event.x;
const dy = +this.getAttribute("y") - d3.event.y;
d3.event.on("drag", function () {
const x = d3.event.x,
y = d3.event.y;
this.setAttribute("x", dx + x);
this.setAttribute("y", dy + y);
});
}
function restoreEditMode() {
if (!reliefTools.querySelector("button.pressed")) enterIndividualMode();
else if (reliefBulkAdd.classList.contains("pressed")) enterBulkAddMode();
else if (reliefBulkRemove.classList.contains("pressed")) enterBulkRemoveMode();
}
function updateReliefIconSelected() {
const type = elSelected.attr("href") || elSelected.attr("data-type");
const button = reliefIconsDiv.querySelector("svg[data-type='" + type + "']");
reliefIconsDiv.querySelectorAll("svg.pressed").forEach(b => b.classList.remove("pressed"));
button.classList.add("pressed");
reliefIconsDiv.querySelectorAll("div").forEach(b => (b.style.display = "none"));
button.parentNode.style.display = "block";
reliefEditorSet.value = button.parentNode.dataset.type;
}
function updateReliefSizeInput() {
const size = +elSelected.attr("width");
reliefSize.value = reliefSizeNumber.value = rn(size);
}
function enterIndividualMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefIndividual.classList.add("pressed");
reliefSizeDiv.style.display = "block";
reliefRadiusDiv.style.display = "none";
reliefSpacingDiv.style.display = "none";
reliefIconsSeletionAny.style.display = "none";
removeCircle();
updateReliefSizeInput();
restoreDefaultEvents();
clearMainTip();
}
function enterBulkAddMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefBulkAdd.classList.add("pressed");
reliefSizeDiv.style.display = "block";
reliefRadiusDiv.style.display = "block";
reliefSpacingDiv.style.display = "block";
reliefIconsSeletionAny.style.display = "none";
const pressedType = reliefIconsDiv.querySelector("svg.pressed");
if (pressedType.id === "reliefIconsSeletionAny") {
// in "any" is pressed, select first type
reliefIconsSeletionAny.classList.remove("pressed");
reliefIconsDiv.querySelector("svg").classList.add("pressed");
}
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragToAdd)).on("touchmove mousemove", moveBrush);
tip("Drag to place relief icons within radius", true);
}
function moveBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +reliefRadiusNumber.value;
moveCircle(point[0], point[1], radius);
}
function dragToAdd() {
const pressed = reliefIconsDiv.querySelector("svg.pressed");
if (!pressed) return tip("Please select an icon", false, error);
const type = pressed.dataset.type;
const r = +reliefRadiusNumber.value;
const spacing = +reliefSpacingNumber.value;
const size = +reliefSizeNumber.value;
// build a quadtree
const tree = d3.quadtree();
const positions = [];
terrain.selectAll("use").each(function () {
const x = +this.getAttribute("x") + this.getAttribute("width") / 2;
const y = +this.getAttribute("y") + this.getAttribute("height") / 2;
tree.add([x, y, x]);
const box = this.getBBox();
positions.push(box.y + box.height);
});
d3.event.on("drag", function () {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
d3.range(Math.ceil(r / 10)).forEach(function () {
const a = Math.PI * 2 * Math.random();
const rad = r * Math.random();
const cx = p[0] + rad * Math.cos(a);
const cy = p[1] + rad * Math.sin(a);
if (tree.find(cx, cy, spacing)) return; // too close to existing icon
if (pack.cells.h[findCell(cx, cy)] < 20) return; // on water cell
const h = rn((size / 2) * (Math.random() * 0.4 + 0.8), 2);
const x = rn(cx - h, 2);
const y = rn(cy - h, 2);
const z = y + h * 2;
const s = rn(h * 2, 2);
let nth = 1;
while (positions[nth] && z > positions[nth]) {
nth++;
}
tree.add([cx, cy]);
positions.push(z);
terrain
.insert("use", ":nth-child(" + nth + ")")
.attr("href", type)
.attr("x", x)
.attr("y", y)
.attr("width", s)
.attr("height", s);
});
});
}
function enterBulkRemoveMode() {
reliefTools.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
reliefBulkRemove.classList.add("pressed");
reliefSizeDiv.style.display = "none";
reliefRadiusDiv.style.display = "block";
reliefSpacingDiv.style.display = "none";
reliefIconsSeletionAny.style.display = "inline-block";
viewbox.style("cursor", "crosshair").call(d3.drag().on("start", dragToRemove)).on("touchmove mousemove", moveBrush);
tip("Drag to remove relief icons in radius", true);
}
function dragToRemove() {
const pressed = reliefIconsDiv.querySelector("svg.pressed");
if (!pressed) return tip("Please select an icon", false, error);
const r = +reliefRadiusNumber.value;
const type = pressed.dataset.type;
const icons = type ? terrain.selectAll("use[href='" + type + "']") : terrain.selectAll("use");
const tree = d3.quadtree();
icons.each(function () {
const x = +this.getAttribute("x") + this.getAttribute("width") / 2;
const y = +this.getAttribute("y") + this.getAttribute("height") / 2;
tree.add([x, y, this]);
});
d3.event.on("drag", function () {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
tree.findAll(p[0], p[1], r).forEach(f => f[2].remove());
});
}
function changeIconSize() {
const size = +reliefSizeNumber.value;
if (!reliefIndividual.classList.contains("pressed")) return;
const shift = (size - +elSelected.attr("width")) / 2;
elSelected.attr("width", size).attr("height", size);
const x = +elSelected.attr("x"),
y = +elSelected.attr("y");
elSelected.attr("x", x - shift).attr("y", y - shift);
}
function changeIconsSet() {
const set = reliefEditorSet.value;
reliefIconsDiv.querySelectorAll("div").forEach(b => (b.style.display = "none"));
reliefIconsDiv.querySelector("div[data-type='" + set + "']").style.display = "block";
}
function changeIcon() {
if (this.classList.contains("pressed")) return;
reliefIconsDiv.querySelectorAll("svg.pressed").forEach(b => b.classList.remove("pressed"));
this.classList.add("pressed");
if (reliefIndividual.classList.contains("pressed")) {
const type = this.dataset.type;
elSelected.attr("href", type);
}
}
function copyIcon() {
const parent = elSelected.node().parentNode;
const copy = elSelected.node().cloneNode(true);
let x = +elSelected.attr("x") - 3,
y = +elSelected.attr("y") - 3;
while (parent.querySelector("[x='" + x + "']", "[x='" + y + "']")) {
x -= 3;
y -= 3;
}
copy.setAttribute("x", x);
copy.setAttribute("y", y);
parent.insertBefore(copy, null);
}
function removeIcon() {
let selection = null;
const pressed = reliefTools.querySelector("button.pressed");
if (pressed.id === "reliefIndividual") {
alertMessage.innerHTML = "Are you sure you want to remove the icon?";
selection = elSelected;
} else {
const type = reliefIconsDiv.querySelector("svg.pressed")?.dataset.type;
selection = type ? terrain.selectAll("use[href='" + type + "']") : terrain.selectAll("use");
const size = selection.size();
alertMessage.innerHTML = type
? `Are you sure you want to remove all ${type} icons (${size})?`
: `Are you sure you want to remove all icons (${size})?`;
}
$("#alert").dialog({
resizable: false,
title: "Remove relief icons",
buttons: {
Remove: function () {
if (selection) selection.remove();
$(this).dialog("close");
$("#reliefEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function closeReliefEditor() {
terrain.selectAll("use").call(d3.drag().on("drag", null)).classed("draggable", false);
removeCircle();
unselect();
clearMainTip();
}
}

View file

@ -0,0 +1,146 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {getPackPolygon, findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
export function createRiver() {
if (customization) return;
closeDialogs();
if (!layerIsOn("toggleRivers")) toggleRivers();
document.getElementById("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
if (!layerIsOn("toggleCells")) toggleCells();
tip("Click to add river point, click again to remove", true);
debug.append("g").attr("id", "controlCells");
viewbox.style("cursor", "crosshair").on("click", onCellClick);
createRiver.cells = [];
const body = document.getElementById("riverCreatorBody");
$("#riverCreator").dialog({
title: "Create River",
resizable: false,
position: {my: "left top", at: "left+10 top+10", of: "#map"},
close: closeRiverCreator
});
if (fmg.modules.createRiver) return;
fmg.modules.createRiver = true;
// add listeners
document.getElementById("riverCreatorComplete").addEventListener("click", addRiver);
document.getElementById("riverCreatorCancel").addEventListener("click", () => $("#riverCreator").dialog("close"));
body.addEventListener("click", function (ev) {
const el = ev.target;
const cl = el.classList;
const cell = +el.parentNode.dataset.cell;
if (cl.contains("editFlux")) pack.cells.fl[cell] = +el.value;
else if (cl.contains("icon-trash-empty")) removeCell(cell);
});
function onCellClick() {
const cell = findCell(...d3.mouse(this));
if (createRiver.cells.includes(cell)) removeCell(cell);
else addCell(cell);
}
function addCell(cell) {
createRiver.cells.push(cell);
drawCells(createRiver.cells);
const flux = pack.cells.fl[cell];
const line = `<div class="editorLine" data-cell="${cell}">
<span>Cell ${cell}</span>
<span data-tip="Set flux affects river width" style="margin-left: 0.4em">Flux</span>
<input type="number" min=0 value="${flux}" class="editFlux" style="width: 5em"/>
<span data-tip="Remove the cell" class="icon-trash-empty pointer"></span>
</div>`;
body.innerHTML += line;
}
function removeCell(cell) {
createRiver.cells = createRiver.cells.filter(c => c !== cell);
drawCells(createRiver.cells);
body.querySelector(`div[data-cell='${cell}']`)?.remove();
}
function drawCells(cells) {
debug
.select("#controlCells")
.selectAll(`polygon`)
.data(cells)
.join("polygon")
.attr("points", d => getPackPolygon(d))
.attr("class", "current");
}
function addRiver() {
const {rivers, cells} = pack;
const {addMeandering, getApproximateLength, getWidth, getOffset, getName, getRiverPath, getBasin} = Rivers;
const riverCells = createRiver.cells;
if (riverCells.length < 2) return tip("Add at least 2 cells", false, "error");
const riverId = rivers.length ? last(rivers).i + 1 : 1;
const parent = cells.r[last(riverCells)] || riverId;
riverCells.forEach(cell => {
if (!cells.r[cell]) cells.r[cell] = riverId;
});
const source = riverCells[0];
const mouth = parent === riverId ? last(riverCells) : riverCells[riverCells.length - 2];
const sourceWidth = 0.05;
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor = 1.2 * defaultWidthFactor;
const meanderedPoints = addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const name = getName(mouth);
const basin = getBasin(parent);
rivers.push({
i: riverId,
source,
mouth,
discharge,
length,
width,
widthFactor,
sourceWidth,
parent,
cells: riverCells,
basin,
name,
type: "River"
});
const id = "river" + riverId;
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
viewbox
.select("#rivers")
.append("path")
.attr("id", id)
.attr("d", getRiverPath(meanderedPoints, widthFactor, sourceWidth));
editRiver(id);
}
function closeRiverCreator() {
body.innerHTML = "";
debug.select("#controlCells").remove();
restoreDefaultEvents();
clearMainTip();
const forced = +document.getElementById("toggleCells").dataset.forced;
document.getElementById("toggleCells").dataset.forced = 0;
if (forced && layerIsOn("toggleCells")) toggleCells();
}
}

View file

@ -0,0 +1,281 @@
import {findCell, getPackPolygon} from "/src/utils/graphUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {getSegmentId} from "/src/utils/lineUtils";
import {rn} from "/src/utils/numberUtils";
import {rand} from "/src/utils/probabilityUtils";
export function editRiver(id) {
if (customization) return;
if (elSelected && id === elSelected.attr("id")) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRivers")) toggleRivers();
document.getElementById("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
if (!layerIsOn("toggleCells")) toggleCells();
elSelected = d3.select("#" + id).on("click", addControlPoint);
tip(
"Drag control points to change the river course. Click on point to remove it. Click on river to add additional control point. For major changes please create a new river instead",
true
);
debug.append("g").attr("id", "controlCells");
debug.append("g").attr("id", "controlPoints");
updateRiverData();
const river = getRiver();
const {cells, points} = river;
const riverPoints = Rivers.getRiverPoints(cells, points);
drawControlPoints(riverPoints);
drawCells(cells);
$("#riverEditor").dialog({
title: "Edit River",
resizable: false,
position: {my: "left top", at: "left+10 top+10", of: "#map"},
close: closeRiverEditor
});
if (fmg.modules.editRiver) return;
fmg.modules.editRiver = true;
// add listeners
document.getElementById("riverCreateSelectingCells").addEventListener("click", createRiver);
document.getElementById("riverEditStyle").addEventListener("click", () => editStyle("rivers"));
document.getElementById("riverElevationProfile").addEventListener("click", showElevationProfile);
document.getElementById("riverLegend").addEventListener("click", editRiverLegend);
document.getElementById("riverRemove").addEventListener("click", removeRiver);
document.getElementById("riverName").addEventListener("input", changeName);
document.getElementById("riverType").addEventListener("input", changeType);
document.getElementById("riverNameCulture").addEventListener("click", generateNameCulture);
document.getElementById("riverNameRandom").addEventListener("click", generateNameRandom);
document.getElementById("riverMainstem").addEventListener("change", changeParent);
document.getElementById("riverSourceWidth").addEventListener("input", changeSourceWidth);
document.getElementById("riverWidthFactor").addEventListener("input", changeWidthFactor);
function getRiver() {
const riverId = +elSelected.attr("id").slice(5);
const river = pack.rivers.find(r => r.i === riverId);
return river;
}
function updateRiverData() {
const r = getRiver();
document.getElementById("riverName").value = r.name;
document.getElementById("riverType").value = r.type;
const parentSelect = document.getElementById("riverMainstem");
parentSelect.options.length = 0;
const parent = r.parent || r.i;
const sortedRivers = pack.rivers.slice().sort((a, b) => (a.name > b.name ? 1 : -1));
sortedRivers.forEach(river => {
const opt = new Option(river.name, river.i, false, river.i === parent);
parentSelect.options.add(opt);
});
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
document.getElementById("riverDischarge").value = r.discharge + " m³/s";
document.getElementById("riverSourceWidth").value = r.sourceWidth;
document.getElementById("riverWidthFactor").value = r.widthFactor;
updateRiverLength(r);
updateRiverWidth(r);
}
function updateRiverLength(river) {
river.length = rn(elSelected.node().getTotalLength() / 2, 2);
const lengthUI = `${rn(river.length * distanceScaleInput.value)} ${distanceUnitInput.value}`;
document.getElementById("riverLength").value = lengthUI;
}
function updateRiverWidth(river) {
const {addMeandering, getWidth, getOffset} = Rivers;
const {cells, discharge, widthFactor, sourceWidth} = river;
const meanderedPoints = addMeandering(cells);
river.width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const width = `${rn(river.width * distanceScaleInput.value, 3)} ${distanceUnitInput.value}`;
document.getElementById("riverWidth").value = width;
}
function drawControlPoints(points) {
debug
.select("#controlPoints")
.selectAll("circle")
.data(points)
.join("circle")
.attr("cx", d => d[0])
.attr("cy", d => d[1])
.attr("r", 0.6)
.call(d3.drag().on("start", dragControlPoint))
.on("click", removeControlPoint);
}
function drawCells(cells) {
const validCells = [...new Set(cells)].filter(i => pack.cells.i[i]);
debug
.select("#controlCells")
.selectAll(`polygon`)
.data(validCells)
.join("polygon")
.attr("points", d => getPackPolygon(d));
}
function dragControlPoint() {
const {r, fl} = pack.cells;
const river = getRiver();
const {x: x0, y: y0} = d3.event;
const initCell = findCell(x0, y0);
let movedToCell = null;
d3.event.on("drag", function () {
const {x, y} = d3.event;
const currentCell = findCell(x, y);
movedToCell = initCell !== currentCell ? currentCell : null;
this.setAttribute("cx", x);
this.setAttribute("cy", y);
this.__data__ = [rn(x, 1), rn(y, 1)];
redrawRiver();
drawCells(river.cells);
});
d3.event.on("end", () => {
if (movedToCell && !r[movedToCell]) {
// swap river data
r[initCell] = 0;
r[movedToCell] = river.i;
const sourceFlux = fl[initCell];
fl[initCell] = fl[movedToCell];
fl[movedToCell] = sourceFlux;
redrawRiver();
}
});
}
function redrawRiver() {
const river = getRiver();
river.points = debug.selectAll("#controlPoints > *").data();
river.cells = river.points.map(([x, y]) => findCell(x, y));
const {widthFactor, sourceWidth} = river;
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth);
elSelected.attr("d", path);
updateRiverLength(river);
if (fmg.modules.elevation) showEPForRiver(elSelected.node());
}
function addControlPoint() {
const [x, y] = d3.mouse(this);
const point = [rn(x, 1), rn(y, 1)];
const river = getRiver();
if (!river.points) river.points = debug.selectAll("#controlPoints > *").data();
const index = getSegmentId(river.points, point, 2);
river.points.splice(index, 0, point);
drawControlPoints(river.points);
redrawRiver();
}
function removeControlPoint() {
this.remove();
redrawRiver();
const {cells} = getRiver();
drawCells(cells);
}
function changeName() {
getRiver().name = this.value;
}
function changeType() {
getRiver().type = this.value;
}
function generateNameCulture() {
const r = getRiver();
r.name = riverName.value = Rivers.getName(r.mouth);
}
function generateNameRandom() {
const r = getRiver();
if (r) r.name = riverName.value = Names.getBase(rand(nameBases.length - 1));
}
function changeParent() {
const r = getRiver();
r.parent = +this.value;
r.basin = pack.rivers.find(river => river.i === r.parent).basin;
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
}
function changeSourceWidth() {
const river = getRiver();
river.sourceWidth = +this.value;
updateRiverWidth(river);
redrawRiver();
}
function changeWidthFactor() {
const river = getRiver();
river.widthFactor = +this.value;
updateRiverWidth(river);
redrawRiver();
}
function showElevationProfile() {
fmg.modules.elevation = true;
showEPForRiver(elSelected.node());
}
function editRiverLegend() {
const id = elSelected.attr("id");
const river = getRiver();
editNotes(id, river.name + " " + river.type);
}
function removeRiver() {
alertMessage.innerHTML = "Are you sure you want to remove the river and all its tributaries";
$("#alert").dialog({
resizable: false,
width: "22em",
title: "Remove river and tributaries",
buttons: {
Remove: function () {
$(this).dialog("close");
const river = +elSelected.attr("id").slice(5);
Rivers.remove(river);
elSelected.remove();
$("#riverEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function closeRiverEditor() {
debug.select("#controlPoints").remove();
debug.select("#controlCells").remove();
elSelected.on("click", null);
unselect();
clearMainTip();
const forced = +document.getElementById("toggleCells").dataset.forced;
document.getElementById("toggleCells").dataset.forced = 0;
if (forced && layerIsOn("toggleCells")) toggleCells();
}
}

View file

@ -0,0 +1,204 @@
import {rn} from "/src/utils/numberUtils";
export function overviewRivers() {
if (customization) return;
closeDialogs("#riversOverview, .stable");
if (!layerIsOn("toggleRivers")) toggleRivers();
const body = document.getElementById("riversBody");
riversOverviewAddLines();
$("#riversOverview").dialog();
if (fmg.modules.overviewRivers) return;
fmg.modules.overviewRivers = true;
$("#riversOverview").dialog({
title: "Rivers Overview",
resizable: false,
width: "fit-content",
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("riversOverviewRefresh").addEventListener("click", riversOverviewAddLines);
document.getElementById("addNewRiver").addEventListener("click", toggleAddRiver);
document.getElementById("riverCreateNew").addEventListener("click", createRiver);
document.getElementById("riversBasinHighlight").addEventListener("click", toggleBasinsHightlight);
document.getElementById("riversExport").addEventListener("click", downloadRiversData);
document.getElementById("riversRemoveAll").addEventListener("click", triggerAllRiversRemove);
// add line for each river
function riversOverviewAddLines() {
body.innerHTML = "";
let lines = "";
const unit = distanceUnitInput.value;
for (const r of pack.rivers) {
const discharge = r.discharge + " m³/s";
const length = rn(r.length * distanceScaleInput.value) + " " + unit;
const width = rn(r.width * distanceScaleInput.value, 3) + " " + unit;
const basin = pack.rivers.find(river => river.i === r.basin)?.name;
lines += /* html */ `<div
class="states"
data-id=${r.i}
data-name="${r.name}"
data-type="${r.type}"
data-discharge="${r.discharge}"
data-length="${r.length}"
data-width="${r.width}"
data-basin="${basin}"
>
<span data-tip="Click to focus on river" class="icon-dot-circled pointer"></span>
<div data-tip="River name" class="riverName">${r.name}</div>
<div data-tip="River type name" class="riverType">${r.type}</div>
<div data-tip="River discharge (flux power)" class="biomeArea">${discharge}</div>
<div data-tip="River length from source to mouth" class="biomeArea">${length}</div>
<div data-tip="River mouth width" class="biomeArea">${width}</div>
<input data-tip="River basin (name of the main stem)" class="stateName" value="${basin}" disabled />
<span data-tip="Edit river" class="icon-pencil"></span>
<span data-tip="Remove river" class="icon-trash-empty"></span>
</div>`;
}
body.insertAdjacentHTML("beforeend", lines);
// update footer
riversFooterNumber.innerHTML = pack.rivers.length;
const averageDischarge = rn(d3.mean(pack.rivers.map(r => r.discharge)));
riversFooterDischarge.innerHTML = averageDischarge + " m³/s";
const averageLength = rn(d3.mean(pack.rivers.map(r => r.length)));
riversFooterLength.innerHTML = averageLength * distanceScaleInput.value + " " + unit;
const averageWidth = rn(d3.mean(pack.rivers.map(r => r.width)), 3);
riversFooterWidth.innerHTML = rn(averageWidth * distanceScaleInput.value, 3) + " " + unit;
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => riverHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => riverHighlightOff(ev)));
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.addEventListener("click", zoomToRiver));
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.addEventListener("click", openRiverEditor));
body
.querySelectorAll("div > span.icon-trash-empty")
.forEach(el => el.addEventListener("click", triggerRiverRemove));
applySorting(riversHeader);
}
function riverHighlightOn(event) {
if (!layerIsOn("toggleRivers")) toggleRivers();
const r = +event.target.dataset.id;
rivers
.select("#river" + r)
.attr("stroke", "red")
.attr("stroke-width", 1);
}
function riverHighlightOff(e) {
const r = +e.target.dataset.id;
rivers
.select("#river" + r)
.attr("stroke", null)
.attr("stroke-width", null);
}
function zoomToRiver() {
const r = +this.parentNode.dataset.id;
const river = rivers.select("#river" + r).node();
highlightElement(river, 3);
}
function toggleBasinsHightlight() {
if (rivers.attr("data-basin") === "hightlighted") {
rivers.selectAll("*").attr("fill", null);
rivers.attr("data-basin", null);
} else {
rivers.attr("data-basin", "hightlighted");
const basins = [...new Set(pack.rivers.map(r => r.basin))];
const colors = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf"
];
basins.forEach((b, i) => {
const color = colors[i % colors.length];
pack.rivers
.filter(r => r.basin === b)
.forEach(r => {
rivers.select("#river" + r.i).attr("fill", color);
});
});
}
}
function downloadRiversData() {
let data = "Id,River,Type,Discharge,Length,Width,Basin\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
const d = el.dataset;
const discharge = d.discharge + " m³/s";
const length = rn(d.length * distanceScaleInput.value) + " " + distanceUnitInput.value;
const width = rn(d.width * distanceScaleInput.value, 3) + " " + distanceUnitInput.value;
data += [d.id, d.name, d.type, discharge, length, width, d.basin].join(",") + "\n";
});
const name = getFileName("Rivers") + ".csv";
downloadFile(data, name);
}
function openRiverEditor() {
const id = "river" + this.parentNode.dataset.id;
editRiver(id);
}
function triggerRiverRemove() {
const river = +this.parentNode.dataset.id;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove the river? All tributaries will be auto-removed`;
$("#alert").dialog({
resizable: false,
width: "22em",
title: "Remove river",
buttons: {
Remove: function () {
Rivers.remove(river);
riversOverviewAddLines();
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function triggerAllRiversRemove() {
alertMessage.innerHTML = /* html */ `Are you sure you want to remove all rivers?`;
$("#alert").dialog({
resizable: false,
title: "Remove all rivers",
buttons: {
Remove: function () {
$(this).dialog("close");
removeAllRivers();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function removeAllRivers() {
pack.rivers = [];
pack.cells.r = new Uint16Array(pack.cells.i.length);
rivers.selectAll("*").remove();
riversOverviewAddLines();
}
}

View file

@ -0,0 +1,325 @@
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {getSegmentId} from "/src/utils/lineUtils";
import {rn} from "/src/utils/numberUtils";
import {getNextId} from "/src/utils/nodeUtils";
import {round} from "/src/utils/stringUtils";
export function editRoute(onClick) {
if (customization) return;
if (!onClick && elSelected && d3.event.target.id === elSelected.attr("id")) return;
closeDialogs(".stable");
if (!layerIsOn("toggleRoutes")) toggleRoutes();
$("#routeEditor").dialog({
title: "Edit Route",
resizable: false,
position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"},
close: closeRoutesEditor
});
debug.append("g").attr("id", "controlPoints");
const node = onClick ? elSelected.node() : d3.event.target;
elSelected = d3.select(node).on("click", addInterimControlPoint);
drawControlPoints(node);
selectRouteGroup(node);
viewbox.on("touchmove mousemove", showEditorTips);
if (onClick) toggleRouteCreationMode();
if (fmg.modules.editRoute) return;
fmg.modules.editRoute = true;
// add listeners
document.getElementById("routeGroupsShow").addEventListener("click", showGroupSection);
document.getElementById("routeGroup").addEventListener("change", changeRouteGroup);
document.getElementById("routeGroupAdd").addEventListener("click", toggleNewGroupInput);
document.getElementById("routeGroupName").addEventListener("change", createNewGroup);
document.getElementById("routeGroupRemove").addEventListener("click", removeRouteGroup);
document.getElementById("routeGroupsHide").addEventListener("click", hideGroupSection);
document.getElementById("routeElevationProfile").addEventListener("click", showElevationProfile);
document.getElementById("routeEditStyle").addEventListener("click", editGroupStyle);
document.getElementById("routeSplit").addEventListener("click", toggleRouteSplitMode);
document.getElementById("routeLegend").addEventListener("click", editRouteLegend);
document.getElementById("routeNew").addEventListener("click", toggleRouteCreationMode);
document.getElementById("routeRemove").addEventListener("click", removeRoute);
function showEditorTips() {
showMainTip();
if (routeNew.classList.contains("pressed")) return;
if (d3.event.target.id === elSelected.attr("id")) tip("Click to add a control point");
else if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point");
}
function drawControlPoints(node) {
const l = node.getTotalLength();
const increment = l / Math.ceil(l / 4);
for (let i = 0; i <= l; i += increment) {
const point = node.getPointAtLength(i);
addControlPoint([point.x, point.y]);
}
routeLength.innerHTML = rn(l * distanceScaleInput.value) + " " + distanceUnitInput.value;
}
function addControlPoint(point, before = null) {
debug
.select("#controlPoints")
.insert("circle", before)
.attr("cx", point[0])
.attr("cy", point[1])
.attr("r", 0.6)
.call(d3.drag().on("drag", dragControlPoint))
.on("click", clickControlPoint);
}
function addInterimControlPoint() {
const point = d3.mouse(this);
const controls = document.getElementById("controlPoints").querySelectorAll("circle");
const points = Array.from(controls).map(circle => [+circle.getAttribute("cx"), +circle.getAttribute("cy")]);
const index = getSegmentId(points, point, 2);
addControlPoint(point, ":nth-child(" + (index + 1) + ")");
redrawRoute();
}
function dragControlPoint() {
this.setAttribute("cx", d3.event.x);
this.setAttribute("cy", d3.event.y);
redrawRoute();
}
function redrawRoute() {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const points = [];
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
});
elSelected.attr("d", round(lineGen(points)));
const l = elSelected.node().getTotalLength();
routeLength.innerHTML = rn(l * distanceScaleInput.value) + " " + distanceUnitInput.value;
if (fmg.modules.elevation) showEPForRoute(elSelected.node());
}
function showElevationProfile() {
fmg.modules.elevation = true;
showEPForRoute(elSelected.node());
}
function showGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "none"));
document.getElementById("routeGroupsSelection").style.display = "inline-block";
}
function hideGroupSection() {
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "inline-block"));
document.getElementById("routeGroupsSelection").style.display = "none";
document.getElementById("routeGroupName").style.display = "none";
document.getElementById("routeGroupName").value = "";
document.getElementById("routeGroup").style.display = "inline-block";
}
function selectRouteGroup(node) {
const group = node.parentNode.id;
const select = document.getElementById("routeGroup");
select.options.length = 0; // remove all options
routes.selectAll("g").each(function () {
select.options.add(new Option(this.id, this.id, false, this.id === group));
});
}
function changeRouteGroup() {
document.getElementById(this.value).appendChild(elSelected.node());
}
function toggleNewGroupInput() {
if (routeGroupName.style.display === "none") {
routeGroupName.style.display = "inline-block";
routeGroupName.focus();
routeGroup.style.display = "none";
} else {
routeGroupName.style.display = "none";
routeGroup.style.display = "inline-block";
}
}
function createNewGroup() {
if (!this.value) {
tip("Please provide a valid group name");
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;
}
// just rename if only 1 element left
const oldGroup = elSelected.node().parentNode;
const basic = ["roads", "trails", "searoutes"].includes(oldGroup.id);
if (!basic && oldGroup.childElementCount === 1) {
document.getElementById("routeGroup").selectedOptions[0].remove();
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
oldGroup.id = group;
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
return;
}
const newGroup = elSelected.node().parentNode.cloneNode(false);
document.getElementById("routes").appendChild(newGroup);
newGroup.id = group;
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
document.getElementById(group).appendChild(elSelected.node());
toggleNewGroupInput();
document.getElementById("routeGroupName").value = "";
}
function removeRouteGroup() {
const group = elSelected.node().parentNode.id;
const basic = ["roads", "trails", "searoutes"].includes(group);
const count = elSelected.node().parentNode.childElementCount;
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
basic ? "all elements in the group" : "the entire route group"
}? <br /><br />Routes to be
removed: ${count}`;
$("#alert").dialog({
resizable: false,
title: "Remove route group",
buttons: {
Remove: function () {
$(this).dialog("close");
$("#routeEditor").dialog("close");
hideGroupSection();
if (basic)
routes
.select("#" + group)
.selectAll("path")
.remove();
else routes.select("#" + group).remove();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function editGroupStyle() {
const g = elSelected.node().parentNode.id;
editStyle("routes", g);
}
function toggleRouteSplitMode() {
document.getElementById("routeNew").classList.remove("pressed");
this.classList.toggle("pressed");
}
function clickControlPoint() {
if (routeSplit.classList.contains("pressed")) splitRoute(this);
else {
this.remove();
redrawRoute();
}
}
function splitRoute(clicked) {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const group = d3.select(elSelected.node().parentNode);
routeSplit.classList.remove("pressed");
const points1 = [],
points2 = [];
let points = points1;
debug
.select("#controlPoints")
.selectAll("circle")
.each(function () {
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
if (this === clicked) {
points = points2;
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
}
this.remove();
});
elSelected.attr("d", round(lineGen(points1)));
const id = getNextId("route");
group.append("path").attr("id", id).attr("d", lineGen(points2));
debug.select("#controlPoints").selectAll("circle").remove();
drawControlPoints(elSelected.node());
}
function toggleRouteCreationMode() {
document.getElementById("routeSplit").classList.remove("pressed");
document.getElementById("routeNew").classList.toggle("pressed");
if (document.getElementById("routeNew").classList.contains("pressed")) {
tip("Click on map to add control points", true);
viewbox.on("click", addPointOnClick).style("cursor", "crosshair");
elSelected.on("click", null);
} else {
clearMainTip();
viewbox.on("click", clicked).style("cursor", "default");
elSelected.on("click", addInterimControlPoint).attr("data-new", null);
}
}
function addPointOnClick() {
// create new route
if (!elSelected.attr("data-new")) {
debug.select("#controlPoints").selectAll("circle").remove();
const parent = elSelected.node().parentNode;
const id = getNextId("route");
elSelected = d3.select(parent).append("path").attr("id", id).attr("data-new", 1);
}
addControlPoint(d3.mouse(this));
redrawRoute();
}
function editRouteLegend() {
const id = elSelected.attr("id");
editNotes(id, id);
}
function removeRoute() {
alertMessage.innerHTML = "Are you sure you want to remove the route?";
$("#alert").dialog({
resizable: false,
title: "Remove route",
buttons: {
Remove: function () {
$(this).dialog("close");
elSelected.remove();
$("#routeEditor").dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function closeRoutesEditor() {
elSelected.attr("data-new", null).on("click", null);
clearMainTip();
routeSplit.classList.remove("pressed");
routeNew.classList.remove("pressed");
debug.select("#controlPoints").remove();
unselect();
}
}

851
src/modules/ui/style.js Normal file
View file

@ -0,0 +1,851 @@
import {tip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {parseTransform} from "/src/utils/stringUtils";
import {getBase64} from "/src/utils/functionUtils";
// add available filters to lists
{
const filters = Array.from(document.getElementById("filters").querySelectorAll("filter"));
const emptyOption = '<option value="" selected>None</option>';
const options = filters.map(filter => {
const id = filter.getAttribute("id");
const name = filter.getAttribute("name");
return `<option value="url(#${id})">${name}</option>`;
});
const allOptions = emptyOption + options.join("");
document.getElementById("styleFilterInput").innerHTML = allOptions;
document.getElementById("styleStatesBodyFilter").innerHTML = allOptions;
}
// store some style inputs as options
styleElements.addEventListener("change", function (ev) {
if (ev.target.dataset.stored) lock(ev.target.dataset.stored);
});
// select element to be edited
function editStyle(element, group) {
showOptions();
styleTab.click();
styleElementSelect.value = element;
if (group) styleGroupSelect.options.add(new Option(group, group, true, true));
selectStyleElement();
styleElementSelect.classList.add("glow");
if (group) styleGroupSelect.classList.add("glow");
setTimeout(() => {
styleElementSelect.classList.remove("glow");
if (group) styleGroupSelect.classList.remove("glow");
}, 1500);
}
// Toggle style sections on element select
styleElementSelect.addEventListener("change", selectStyleElement);
function selectStyleElement() {
const sel = styleElementSelect.value;
let el = d3.select("#" + sel);
styleElements.querySelectorAll("tbody").forEach(e => (e.style.display = "none")); // hide all sections
// show alert line if layer is not visible
const isLayerOff = sel !== "ocean" && (el.style("display") === "none" || !el.selectAll("*").size());
styleIsOff.style.display = isLayerOff ? "block" : "none";
// active group element
const group = styleGroupSelect.value;
if (["routes", "labels", "coastline", "lakes", "anchors", "burgIcons", "borders"].includes(sel)) {
const gEl = group && el.select("#" + group);
el = group && gEl.size() ? gEl : el.select("g");
}
// opacity
if (!["landmass", "ocean", "regions", "legend"].includes(sel)) {
styleOpacity.style.display = "block";
styleOpacityInput.value = styleOpacityOutput.value = el.attr("opacity") || 1;
}
// filter
if (!["landmass", "legend", "regions"].includes(sel)) {
styleFilter.style.display = "block";
styleFilterInput.value = el.attr("filter") || "";
}
// fill
if (["rivers", "lakes", "landmass", "prec", "ice", "fogging"].includes(sel)) {
styleFill.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill");
}
// stroke color and width
if (
[
"armies",
"routes",
"lakes",
"borders",
"cults",
"relig",
"cells",
"coastline",
"prec",
"ice",
"icons",
"coordinates",
"zones",
"gridOverlay"
].includes(sel)
) {
styleStroke.style.display = "block";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke");
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
}
// stroke dash
if (
["routes", "borders", "temperature", "legend", "population", "coordinates", "zones", "gridOverlay"].includes(sel)
) {
styleStrokeDash.style.display = "block";
styleStrokeDasharrayInput.value = el.attr("stroke-dasharray") || "";
styleStrokeLinecapInput.value = el.attr("stroke-linecap") || "inherit";
}
// clipping
if (
[
"cells",
"gridOverlay",
"coordinates",
"compass",
"terrain",
"temperature",
"routes",
"texture",
"biomes",
"zones"
].includes(sel)
) {
styleClipping.style.display = "block";
styleClippingInput.value = el.attr("mask") || "";
}
// show specific sections
if (sel === "texture") styleTexture.style.display = "block";
if (sel === "terrs") {
styleHeightmap.style.display = "block";
styleHeightmapScheme.value = terrs.attr("scheme");
styleHeightmapTerracingInput.value = styleHeightmapTerracingOutput.value = terrs.attr("terracing");
styleHeightmapSkipInput.value = styleHeightmapSkipOutput.value = terrs.attr("skip");
styleHeightmapSimplificationInput.value = styleHeightmapSimplificationOutput.value = terrs.attr("relax");
styleHeightmapCurve.value = terrs.attr("curve");
}
if (sel === "markers") {
styleMarkers.style.display = "block";
styleRescaleMarkers.checked = +markers.attr("rescale");
}
if (sel === "gridOverlay") {
styleGrid.style.display = "block";
styleGridType.value = el.attr("type");
styleGridScale.value = el.attr("scale") || 1;
styleGridShiftX.value = el.attr("dx") || 0;
styleGridShiftY.value = el.attr("dy") || 0;
calculateFriendlyGridSize();
}
if (sel === "compass") {
styleCompass.style.display = "block";
const tr = parseTransform(compass.select("use").attr("transform"));
styleCompassShiftX.value = tr[0];
styleCompassShiftY.value = tr[1];
styleCompassSizeInput.value = styleCompassSizeOutput.value = tr[2];
}
if (sel === "terrain") {
styleRelief.style.display = "block";
styleReliefSizeOutput.innerHTML = styleReliefSizeInput.value = terrain.attr("size");
styleReliefDensityOutput.innerHTML = styleReliefDensityInput.value = terrain.attr("density");
styleReliefSet.value = terrain.attr("set");
}
if (sel === "population") {
stylePopulation.style.display = "block";
stylePopulationRuralStrokeInput.value = stylePopulationRuralStrokeOutput.value = population
.select("#rural")
.attr("stroke");
stylePopulationUrbanStrokeInput.value = stylePopulationUrbanStrokeOutput.value = population
.select("#urban")
.attr("stroke");
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
}
if (sel === "regions") {
styleStates.style.display = "block";
styleStatesBodyOpacity.value = styleStatesBodyOpacityOutput.value = statesBody.attr("opacity") || 1;
styleStatesBodyFilter.value = statesBody.attr("filter") || "";
styleStatesHaloWidth.value = styleStatesHaloWidthOutput.value = statesHalo.attr("data-width") || 10;
styleStatesHaloOpacity.value = styleStatesHaloOpacityOutput.value = statesHalo.attr("opacity") || 1;
const blur = parseFloat(statesHalo.attr("filter")?.match(/blur\(([^)]+)\)/)?.[1]) || 0;
styleStatesHaloBlur.value = styleStatesHaloBlurOutput.value = blur;
}
if (sel === "labels") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleShadow.style.display = "block";
styleSize.style.display = "block";
styleVisibility.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#3e3e4b";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3a3a3a";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 0;
styleShadowInput.value = el.style("text-shadow") || "white 0 0 4px";
styleFont.style.display = "block";
styleSelectFont.value = el.attr("font-family");
styleFontSize.value = el.attr("data-size");
}
if (sel === "provs") {
styleFill.style.display = "block";
styleSize.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#111111";
styleFont.style.display = "block";
styleSelectFont.value = el.attr("font-family");
styleFontSize.value = el.attr("data-size");
}
if (sel == "burgIcons") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleStrokeDash.style.display = "block";
styleRadius.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#ffffff";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3e3e4b";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 0.24;
styleStrokeDasharrayInput.value = el.attr("stroke-dasharray") || "";
styleStrokeLinecapInput.value = el.attr("stroke-linecap") || "inherit";
styleRadiusInput.value = el.attr("size") || 1;
}
if (sel == "anchors") {
styleFill.style.display = "block";
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleIconSize.style.display = "block";
styleFillInput.value = styleFillOutput.value = el.attr("fill") || "#ffffff";
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#3e3e4b";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 0.24;
styleIconSizeInput.value = el.attr("size") || 2;
}
if (sel === "legend") {
styleStroke.style.display = "block";
styleStrokeWidth.style.display = "block";
styleSize.style.display = "block";
styleLegend.style.display = "block";
styleLegendColItemsOutput.value = styleLegendColItems.value = el.attr("data-columns");
styleLegendBackOutput.value = styleLegendBack.value = el.select("#legendBox").attr("fill");
styleLegendOpacityOutput.value = styleLegendOpacity.value = el.select("#legendBox").attr("fill-opacity");
styleStrokeInput.value = styleStrokeOutput.value = el.attr("stroke") || "#111111";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 0.5;
styleFont.style.display = "block";
styleSelectFont.value = el.attr("font-family");
styleFontSize.value = el.attr("data-size");
}
if (sel === "ocean") {
styleOcean.style.display = "block";
styleOceanFill.value = styleOceanFillOutput.value = oceanLayers.select("#oceanBase").attr("fill");
styleOceanPattern.value = document.getElementById("oceanicPattern")?.getAttribute("href");
styleOceanPatternOpacity.value = styleOceanPatternOpacityOutput.value =
document.getElementById("oceanicPattern").getAttribute("opacity") || 1;
outlineLayers.value = oceanLayers.attr("layers");
}
if (sel === "temperature") {
styleStrokeWidth.style.display = "block";
styleTemperature.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || "";
styleTemperatureFillOpacityInput.value = styleTemperatureFillOpacityOutput.value = el.attr("fill-opacity") || 0.1;
styleTemperatureFillInput.value = styleTemperatureFillOutput.value = el.attr("fill") || "#000";
styleTemperatureFontSizeInput.value = styleTemperatureFontSizeOutput.value = el.attr("font-size") || "8px";
}
if (sel === "coordinates") {
styleSize.style.display = "block";
styleFontSize.value = el.attr("data-size");
}
if (sel === "armies") {
styleArmies.style.display = "block";
styleArmiesFillOpacity.value = styleArmiesFillOpacityOutput.value = el.attr("fill-opacity");
styleArmiesSize.value = styleArmiesSizeOutput.value = el.attr("box-size");
}
if (sel === "emblems") {
styleEmblems.style.display = "block";
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 1;
}
// update group options
styleGroupSelect.options.length = 0; // remove all options
if (["routes", "labels", "coastline", "lakes", "anchors", "burgIcons", "borders"].includes(sel)) {
const groups = document.getElementById(sel).querySelectorAll("g");
groups.forEach(el => {
if (el.id === "burgLabels") return;
const option = new Option(`${el.id} (${el.childElementCount})`, el.id, false, false);
styleGroupSelect.options.add(option);
});
styleGroupSelect.value = el.attr("id");
styleGroup.style.display = "block";
} else {
styleGroupSelect.options.add(new Option(sel, sel, false, true));
styleGroup.style.display = "none";
}
if (sel === "coastline" && styleGroupSelect.value === "sea_island") {
styleCoastline.style.display = "block";
const auto = (styleCoastlineAuto.checked = coastline.select("#sea_island").attr("auto-filter"));
if (auto) styleFilter.style.display = "none";
}
}
// Handle style inputs change
styleGroupSelect.addEventListener("change", selectStyleElement);
function getEl() {
const el = styleElementSelect.value;
const g = styleGroupSelect.value;
if (g === el) return svg.select("#" + el);
else return svg.select("#" + el).select("#" + g);
}
styleFillInput.addEventListener("input", function () {
styleFillOutput.value = this.value;
getEl().attr("fill", this.value);
});
styleStrokeInput.addEventListener("input", function () {
styleStrokeOutput.value = this.value;
getEl().attr("stroke", this.value);
if (styleElementSelect.value === "gridOverlay" && layerIsOn("toggleGrid")) drawGrid();
});
styleStrokeWidthInput.addEventListener("input", function () {
styleStrokeWidthOutput.value = this.value;
getEl().attr("stroke-width", +this.value);
if (styleElementSelect.value === "gridOverlay" && layerIsOn("toggleGrid")) drawGrid();
});
styleStrokeDasharrayInput.addEventListener("input", function () {
getEl().attr("stroke-dasharray", this.value);
if (styleElementSelect.value === "gridOverlay" && layerIsOn("toggleGrid")) drawGrid();
});
styleStrokeLinecapInput.addEventListener("change", function () {
getEl().attr("stroke-linecap", this.value);
if (styleElementSelect.value === "gridOverlay" && layerIsOn("toggleGrid")) drawGrid();
});
styleOpacityInput.addEventListener("input", function () {
styleOpacityOutput.value = this.value;
getEl().attr("opacity", this.value);
});
styleFilterInput.addEventListener("change", function () {
if (styleGroupSelect.value === "ocean") return oceanLayers.attr("filter", this.value);
getEl().attr("filter", this.value);
});
styleTextureInput.addEventListener("change", function () {
if (this.value === "none") texture.select("image").attr("xlink:href", "");
else getBase64(this.value, base64 => texture.select("image").attr("xlink:href", base64));
});
styleTextureShiftX.addEventListener("input", function () {
texture
.select("image")
.attr("x", this.value)
.attr("width", graphWidth - this.valueAsNumber);
});
styleTextureShiftY.addEventListener("input", function () {
texture
.select("image")
.attr("y", this.value)
.attr("height", graphHeight - this.valueAsNumber);
});
styleClippingInput.addEventListener("change", function () {
getEl().attr("mask", this.value);
});
styleGridType.addEventListener("change", function () {
getEl().attr("type", this.value);
if (layerIsOn("toggleGrid")) drawGrid();
calculateFriendlyGridSize();
});
styleGridScale.addEventListener("input", function () {
getEl().attr("scale", this.value);
if (layerIsOn("toggleGrid")) drawGrid();
calculateFriendlyGridSize();
});
function calculateFriendlyGridSize() {
const size = styleGridScale.value * 25;
const friendly = `${rn(size * distanceScaleInput.value, 2)} ${distanceUnitInput.value}`;
styleGridSizeFriendly.value = friendly;
}
styleGridShiftX.addEventListener("input", function () {
getEl().attr("dx", this.value);
if (layerIsOn("toggleGrid")) drawGrid();
});
styleGridShiftY.addEventListener("input", function () {
getEl().attr("dy", this.value);
if (layerIsOn("toggleGrid")) drawGrid();
});
styleShiftX.addEventListener("input", shiftElement);
styleShiftY.addEventListener("input", shiftElement);
function shiftElement() {
const x = styleShiftX.value || 0;
const y = styleShiftY.value || 0;
getEl().attr("transform", `translate(${x},${y})`);
}
styleRescaleMarkers.addEventListener("change", function () {
markers.attr("rescale", +this.checked);
invokeActiveZooming();
});
styleCoastlineAuto.addEventListener("change", function () {
coastline.select("#sea_island").attr("auto-filter", +this.checked);
styleFilter.style.display = this.checked ? "none" : "block";
invokeActiveZooming();
});
styleOceanFill.addEventListener("input", function () {
oceanLayers.select("rect").attr("fill", this.value);
styleOceanFillOutput.value = this.value;
});
styleOceanPattern.addEventListener("change", function () {
document.getElementById("oceanicPattern")?.setAttribute("href", this.value);
});
styleOceanPatternOpacity.addEventListener("input", function () {
document.getElementById("oceanicPattern").setAttribute("opacity", this.value);
styleOceanPatternOpacityOutput.value = this.value;
});
outlineLayers.addEventListener("change", function () {
oceanLayers.selectAll("path").remove();
oceanLayers.attr("layers", this.value);
OceanLayers();
});
styleHeightmapScheme.addEventListener("change", function () {
terrs.attr("scheme", this.value);
drawHeightmap();
});
styleHeightmapTerracingInput.addEventListener("input", function () {
terrs.attr("terracing", this.value);
drawHeightmap();
});
styleHeightmapSkipInput.addEventListener("input", function () {
terrs.attr("skip", this.value);
drawHeightmap();
});
styleHeightmapSimplificationInput.addEventListener("input", function () {
terrs.attr("relax", this.value);
drawHeightmap();
});
styleHeightmapCurve.addEventListener("change", function () {
terrs.attr("curve", this.value);
drawHeightmap();
});
styleReliefSet.addEventListener("change", function () {
terrain.attr("set", this.value);
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
});
styleReliefSizeInput.addEventListener("change", function () {
terrain.attr("size", this.value);
styleReliefSizeOutput.value = this.value;
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
});
styleReliefDensityInput.addEventListener("change", function () {
terrain.attr("density", this.value);
styleReliefDensityOutput.value = this.value;
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
});
styleTemperatureFillOpacityInput.addEventListener("input", function () {
temperature.attr("fill-opacity", this.value);
styleTemperatureFillOpacityOutput.value = this.value;
});
styleTemperatureFontSizeInput.addEventListener("input", function () {
temperature.attr("font-size", this.value + "px");
styleTemperatureFontSizeOutput.value = this.value + "px";
});
styleTemperatureFillInput.addEventListener("input", function () {
temperature.attr("fill", this.value);
styleTemperatureFillOutput.value = this.value;
});
stylePopulationRuralStrokeInput.addEventListener("input", function () {
population.select("#rural").attr("stroke", this.value);
stylePopulationRuralStrokeOutput.value = this.value;
});
stylePopulationUrbanStrokeInput.addEventListener("input", function () {
population.select("#urban").attr("stroke", this.value);
stylePopulationUrbanStrokeOutput.value = this.value;
});
styleCompassSizeInput.addEventListener("input", function () {
styleCompassSizeOutput.value = this.value;
shiftCompass();
});
styleCompassShiftX.addEventListener("input", shiftCompass);
styleCompassShiftY.addEventListener("input", shiftCompass);
function shiftCompass() {
const tr = `translate(${styleCompassShiftX.value} ${styleCompassShiftY.value}) scale(${styleCompassSizeInput.value})`;
compass.select("use").attr("transform", tr);
}
styleLegendColItems.addEventListener("input", function () {
styleLegendColItemsOutput.value = this.value;
legend.select("#legendBox").attr("data-columns", this.value);
redrawLegend();
});
styleLegendBack.addEventListener("input", function () {
styleLegendBackOutput.value = this.value;
legend.select("#legendBox").attr("fill", this.value);
});
styleLegendOpacity.addEventListener("input", function () {
styleLegendOpacityOutput.value = this.value;
legend.select("#legendBox").attr("fill-opacity", this.value);
});
styleSelectFont.addEventListener("change", changeFont);
function changeFont() {
const family = styleSelectFont.value;
getEl().attr("font-family", family);
if (styleElementSelect.value === "legend") redrawLegend();
}
styleShadowInput.addEventListener("input", function () {
getEl().style("text-shadow", this.value);
});
styleFontAdd.addEventListener("click", function () {
addFontNameInput.value = "";
addFontURLInput.value = "";
$("#addFontDialog").dialog({
title: "Add custom font",
width: "26em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Add: function () {
const family = addFontNameInput.value;
const src = addFontURLInput.value;
const method = addFontMethod.value;
if (!family) return tip("Please provide a font name", false, "error");
const existingFont =
method === "fontURL"
? fonts.find(font => font.family === family && font.src === src)
: fonts.find(font => font.family === family);
if (existingFont) return tip("The font is already added", false, "error");
if (method === "fontURL") addWebFont(family, src);
else if (method === "googleFont") addGoogleFont(family);
else if (method === "localFont") addLocalFont(family);
addFontNameInput.value = "";
addFontURLInput.value = "";
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
});
addFontMethod.addEventListener("change", function () {
addFontURLInput.style.display = this.value === "fontURL" ? "inline" : "none";
});
styleFontSize.addEventListener("change", function () {
changeFontSize(getEl(), +this.value);
});
styleFontPlus.addEventListener("click", function () {
const size = +getEl().attr("data-size") + 1;
changeFontSize(getEl(), Math.min(size, 999));
});
styleFontMinus.addEventListener("click", function () {
const size = +getEl().attr("data-size") - 1;
changeFontSize(getEl(), Math.max(size, 1));
});
function changeFontSize(el, size) {
styleFontSize.value = size;
const getSizeOnScale = element => {
// some labels are rescaled on zoom
if (element === "labels") return Math.max(rn((size + size / scale) / 2, 2), 1);
if (element === "coordinates") return rn(size / scale ** 0.8, 2);
// other has the same size
return size;
};
const scaleSize = getSizeOnScale(styleElementSelect.value);
el.attr("data-size", size).attr("font-size", scaleSize);
if (styleElementSelect.value === "legend") redrawLegend();
}
styleRadiusInput.addEventListener("change", function () {
changeRadius(+this.value);
});
styleRadiusPlus.addEventListener("click", function () {
const size = Math.max(rn(getEl().attr("size") * 1.1, 2), 0.2);
changeRadius(size);
});
styleRadiusMinus.addEventListener("click", function () {
const size = Math.max(rn(getEl().attr("size") * 0.9, 2), 0.2);
changeRadius(size);
});
function changeRadius(size, group) {
const el = group ? burgIcons.select("#" + group) : getEl();
const g = el.attr("id");
el.attr("size", size);
el.selectAll("circle").each(function () {
this.setAttribute("r", size);
});
styleRadiusInput.value = size;
burgLabels
.select("g#" + g)
.selectAll("text")
.each(function () {
this.setAttribute("dy", `${size * -1.5}px`);
});
changeIconSize(size * 2, g); // change also anchor icons
}
styleIconSizeInput.addEventListener("change", function () {
changeIconSize(+this.value);
});
styleIconSizePlus.addEventListener("click", function () {
const size = Math.max(rn(getEl().attr("size") * 1.1, 2), 0.2);
changeIconSize(size);
});
styleIconSizeMinus.addEventListener("click", function () {
const size = Math.max(rn(getEl().attr("size") * 0.9, 2), 0.2);
changeIconSize(size);
});
function changeIconSize(size, group) {
const el = group ? anchors.select("#" + group) : getEl();
if (!el.size()) {
console.warn(`Group ${group} not found. Can not set icon size!`);
return;
}
const oldSize = +el.attr("size");
const shift = (size - oldSize) / 2;
el.attr("size", size);
el.selectAll("use").each(function () {
const x = +this.getAttribute("x");
const y = +this.getAttribute("y");
this.setAttribute("x", x - shift);
this.setAttribute("y", y - shift);
this.setAttribute("width", size);
this.setAttribute("height", size);
});
styleIconSizeInput.value = size;
}
styleStatesBodyOpacity.addEventListener("input", function () {
styleStatesBodyOpacityOutput.value = this.value;
statesBody.attr("opacity", this.value);
});
styleStatesBodyFilter.addEventListener("change", function () {
statesBody.attr("filter", this.value);
});
styleStatesHaloWidth.addEventListener("input", function () {
styleStatesHaloWidthOutput.value = this.value;
statesHalo.attr("data-width", this.value).attr("stroke-width", this.value);
});
styleStatesHaloOpacity.addEventListener("input", function () {
styleStatesHaloOpacityOutput.value = this.value;
statesHalo.attr("opacity", this.value);
});
styleStatesHaloBlur.addEventListener("input", function () {
styleStatesHaloBlurOutput.value = this.value;
const blur = +this.value > 0 ? `blur(${this.value}px)` : null;
statesHalo.attr("filter", blur);
});
styleArmiesFillOpacity.addEventListener("input", function () {
armies.attr("fill-opacity", this.value);
styleArmiesFillOpacityOutput.value = this.value;
});
styleArmiesSize.addEventListener("input", function () {
armies.attr("box-size", this.value).attr("font-size", this.value * 2);
styleArmiesSizeOutput.value = this.value;
armies.selectAll("g").remove(); // clear armies layer
pack.states.forEach(s => {
if (!s.i || s.removed || !s.military.length) return;
Military.drawRegiments(s.military, s.i);
});
});
emblemsStateSizeInput.addEventListener("change", () => drawEmblems());
emblemsProvinceSizeInput.addEventListener("change", () => drawEmblems());
emblemsBurgSizeInput.addEventListener("change", () => drawEmblems());
// request a URL to image to be used as a texture
function textureProvideURL() {
alertMessage.innerHTML = /* html */ `Provide an image URL to be used as a texture:
<input id="textureURL" type="url" style="width: 100%" placeholder="http://www.example.com/image.jpg" oninput="fetchTextureURL(this.value)" />
<canvas id="texturePreview" width="256px" height="144px"></canvas>`;
$("#alert").dialog({
resizable: false,
title: "Load custom texture",
width: "26em",
buttons: {
Apply: function () {
const name = textureURL.value.split("/").pop();
if (!name || name === "") {
tip("Please provide a valid URL", false, "error");
return;
}
const opt = document.createElement("option");
opt.value = textureURL.value;
opt.text = name.slice(0, 20);
styleTextureInput.add(opt);
styleTextureInput.value = textureURL.value;
getBase64(textureURL.value, base64 => texture.select("image").attr("xlink:href", base64));
zoom.scaleBy(svg, 1.00001); // enforce browser re-draw
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
function fetchTextureURL(url) {
INFO && console.log("Provided URL is", url);
const img = new Image();
img.onload = function () {
const canvas = document.getElementById("texturePreview");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
};
img.src = url;
}
function updateElements() {
// burgIcons to desired size
burgIcons.selectAll("g").each(function () {
const size = +this.getAttribute("size");
d3.select(this)
.selectAll("circle")
.each(function () {
this.setAttribute("r", size);
});
burgLabels
.select("g#" + this.id)
.selectAll("text")
.each(function () {
this.setAttribute("dy", `${size * -1.5}px`);
});
});
// anchor icons to desired size
anchors.selectAll("g").each(function (d) {
const size = +this.getAttribute("size");
d3.select(this)
.selectAll("use")
.each(function () {
const id = +this.dataset.id;
const x = pack.burgs[id].x,
y = pack.burgs[id].y;
this.setAttribute("x", rn(x - size * 0.47, 2));
this.setAttribute("y", rn(y - size * 0.47, 2));
this.setAttribute("width", size);
this.setAttribute("height", size);
});
});
// redraw elements
if (layerIsOn("toggleHeight")) drawHeightmap();
if (legend.selectAll("*").size() && window.redrawLegend) redrawLegend();
oceanLayers.selectAll("path").remove();
OceanLayers();
invokeActiveZooming();
}
// GLOBAL FILTERS
mapFilters.addEventListener("click", applyMapFilter);
function applyMapFilter(event) {
if (event.target.tagName !== "BUTTON") return;
const button = event.target;
svg.attr("data-filter", null).attr("filter", null);
if (button.classList.contains("pressed")) return button.classList.remove("pressed");
mapFilters.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
button.classList.add("pressed");
svg.attr("data-filter", button.id).attr("filter", "url(#filter-" + button.id + ")");
}

View file

@ -0,0 +1,407 @@
import {tip} from "/src/scripts/tooltips";
import {isJsonValid} from "/src/utils/stringUtils";
const systemPresets = [
"default",
"ancient",
"gloom",
"light",
"watercolor",
"clean",
"atlas",
"cyberpunk",
"monochrome"
];
const customPresetPrefix = "fmgStyle_";
// add style presets to list
{
const systemOptions = systemPresets.map(styleName => `<option value="${styleName}">${styleName}</option>`);
const storedStyles = Object.keys(localStorage).filter(key => key.startsWith(customPresetPrefix));
const customOptions = storedStyles.map(
styleName => `<option value="${styleName}">${styleName.replace(customPresetPrefix, "")} [custom]</option>`
);
const options = systemOptions.join("") + customOptions.join("");
document.getElementById("stylePreset").innerHTML = options;
}
export async function applyStyleOnLoad() {
const desiredPreset = localStorage.getItem("presetStyle") || "default";
const styleData = await getStylePreset(desiredPreset);
const [appliedPreset, style] = styleData;
applyStyle(style);
updateMapFilter();
stylePreset.value = stylePreset.dataset.old = appliedPreset;
setPresetRemoveButtonVisibiliy();
}
async function getStylePreset(desiredPreset) {
let presetToLoad = desiredPreset;
const isCustom = !systemPresets.includes(desiredPreset);
if (isCustom) {
const storedStyleJSON = localStorage.getItem(desiredPreset);
if (!storedStyleJSON) {
ERROR && console.error(`Custom style ${desiredPreset} in not found in localStorage. Applying default style`);
presetToLoad = "default";
} else {
const isValid = isJsonValid(storedStyleJSON);
if (isValid) return [desiredPreset, JSON.parse(storedStyleJSON)];
ERROR &&
console.error(`Custom style ${desiredPreset} stored in localStorage is not valid. Applying default style`);
presetToLoad = "default";
}
}
const style = await fetchSystemPreset(presetToLoad);
return [presetToLoad, style];
}
async function fetchSystemPreset(preset) {
const style = await fetch(`./styles/${preset}.json`)
.then(res => res.json())
.catch(err => {
ERROR && console.error("Error on loading style preset", preset, err);
return null;
});
if (!style) throw new Error("Cannot fetch style preset", preset);
return style;
}
function applyStyle(style) {
for (const selector in style) {
const el = document.querySelector(selector);
if (!el) continue;
for (const attribute in style[selector]) {
const value = style[selector][attribute];
if (value === "null" || value === null) {
el.removeAttribute(attribute);
continue;
}
if (attribute === "text-shadow") {
el.style[attribute] = value;
} else {
el.setAttribute(attribute, value);
}
}
}
}
function requestStylePresetChange(preset) {
const isConfirmed = sessionStorage.getItem("styleChangeConfirmed");
if (isConfirmed) {
changeStyle(preset);
return;
}
confirmationDialog({
title: "Change style preset",
message: "Are you sure you want to change the style preset? All unsaved style changes will be lost",
confirm: "Change",
onConfirm: () => {
sessionStorage.setItem("styleChangeConfirmed", true);
changeStyle(preset);
},
onCancel: () => {
stylePreset.value = stylePreset.dataset.old;
}
});
}
async function changeStyle(desiredPreset) {
const styleData = await getStylePreset(desiredPreset);
const [appliedPreset, style] = styleData;
localStorage.setItem("presetStyle", appliedPreset);
applyStyleWithUiRefresh(style);
}
function applyStyleWithUiRefresh(style) {
applyStyle(style);
updateElements();
selectStyleElement(); // re-select element to trigger values update
updateMapFilter();
stylePreset.dataset.old = stylePreset.value;
invokeActiveZooming();
setPresetRemoveButtonVisibiliy();
}
function addStylePreset() {
$("#styleSaver").dialog({title: "Style Saver", width: "26em", position: {my: "center", at: "center", of: "svg"}});
const styleName = stylePreset.value.replace(customPresetPrefix, "");
document.getElementById("styleSaverName").value = styleName;
styleSaverJSON.value = JSON.stringify(collectStyleData(), null, 2);
checkName();
if (fmg.modules.saveStyle) return;
fmg.modules.saveStyle = true;
// add listeners
document.getElementById("styleSaverName").addEventListener("input", checkName);
document.getElementById("styleSaverSave").addEventListener("click", saveStyle);
document.getElementById("styleSaverDownload").addEventListener("click", styleDownload);
document.getElementById("styleSaverLoad").addEventListener("click", () => styleToLoad.click());
document.getElementById("styleToLoad").addEventListener("change", loadStyleFile);
function collectStyleData() {
const style = {};
const attributes = {
"#map": ["background-color", "filter", "data-filter"],
"#armies": ["font-size", "box-size", "stroke", "stroke-width", "fill-opacity", "filter"],
"#biomes": ["opacity", "filter", "mask"],
"#stateBorders": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter"],
"#provinceBorders": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter"],
"#cells": ["opacity", "stroke", "stroke-width", "filter", "mask"],
"#gridOverlay": [
"opacity",
"scale",
"dx",
"dy",
"type",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap",
"transform",
"filter",
"mask"
],
"#coordinates": [
"opacity",
"data-size",
"font-size",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap",
"filter",
"mask"
],
"#compass": ["opacity", "transform", "filter", "mask", "shape-rendering"],
"#rose": ["transform"],
"#relig": ["opacity", "stroke", "stroke-width", "filter"],
"#cults": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter"],
"#landmass": ["opacity", "fill", "filter"],
"#markers": ["opacity", "rescale", "filter"],
"#prec": ["opacity", "stroke", "stroke-width", "fill", "filter"],
"#population": ["opacity", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter"],
"#rural": ["stroke"],
"#urban": ["stroke"],
"#freshwater": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#salt": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#sinkhole": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#frozen": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#lava": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#dry": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#sea_island": ["opacity", "stroke", "stroke-width", "filter", "auto-filter"],
"#lake_island": ["opacity", "stroke", "stroke-width", "filter"],
"#terrain": ["opacity", "set", "size", "density", "filter", "mask"],
"#rivers": ["opacity", "filter", "fill"],
"#ruler": ["opacity", "filter"],
"#roads": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter", "mask"],
"#trails": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter", "mask"],
"#searoutes": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter", "mask"],
"#statesBody": ["opacity", "filter"],
"#statesHalo": ["opacity", "data-width", "stroke-width", "filter"],
"#provs": ["opacity", "fill", "font-size", "font-family", "filter"],
"#temperature": [
"opacity",
"font-size",
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap",
"filter"
],
"#ice": ["opacity", "fill", "stroke", "stroke-width", "filter"],
"#emblems": ["opacity", "stroke-width", "filter"],
"#texture": ["opacity", "filter", "mask"],
"#textureImage": ["x", "y"],
"#zones": ["opacity", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "filter", "mask"],
"#oceanLayers": ["filter", "layers"],
"#oceanBase": ["fill"],
"#oceanicPattern": ["href", "opacity"],
"#terrs": ["opacity", "scheme", "terracing", "skip", "relax", "curve", "filter", "mask"],
"#legend": [
"data-size",
"font-size",
"font-family",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap",
"data-x",
"data-y",
"data-columns"
],
"#legendBox": ["fill", "fill-opacity"],
"#burgLabels > #cities": ["opacity", "fill", "text-shadow", "data-size", "font-size", "font-family"],
"#burgIcons > #cities": [
"opacity",
"fill",
"fill-opacity",
"size",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap"
],
"#anchors > #cities": ["opacity", "fill", "size", "stroke", "stroke-width"],
"#burgLabels > #towns": ["opacity", "fill", "text-shadow", "data-size", "font-size", "font-family"],
"#burgIcons > #towns": [
"opacity",
"fill",
"fill-opacity",
"size",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-linecap"
],
"#anchors > #towns": ["opacity", "fill", "size", "stroke", "stroke-width"],
"#labels > #states": [
"opacity",
"fill",
"stroke",
"stroke-width",
"text-shadow",
"data-size",
"font-size",
"font-family",
"filter"
],
"#labels > #addedLabels": [
"opacity",
"fill",
"stroke",
"stroke-width",
"text-shadow",
"data-size",
"font-size",
"font-family",
"filter"
],
"#fogging": ["opacity", "fill", "filter"]
};
for (const selector in attributes) {
const el = document.querySelector(selector);
if (!el) continue;
style[selector] = {};
for (const attr of attributes[selector]) {
let value = el.style[attr] || el.getAttribute(attr);
if (attr === "font-size" && el.hasAttribute("data-size")) value = el.getAttribute("data-size");
style[selector][attr] = parseValue(value);
}
}
function parseValue(value) {
if (value === "null" || value === null) return null;
if (value === "") return "";
if (!isNaN(+value)) return +value;
return value;
}
return style;
}
function checkName() {
const styleName = customPresetPrefix + styleSaverName.value;
const isSystem = systemPresets.includes(styleName) || systemPresets.includes(styleSaverName.value);
if (isSystem) return (styleSaverTip.innerHTML = "default");
const isExisting = Array.from(stylePreset.options).some(option => option.value == styleName);
if (isExisting) return (styleSaverTip.innerHTML = "existing");
styleSaverTip.innerHTML = "new";
}
function saveStyle() {
const styleJSON = styleSaverJSON.value;
const desiredName = styleSaverName.value;
if (!styleJSON) return tip("Please provide a style JSON", false, "error");
if (!isJsonValid(styleJSON)) return tip("JSON string is not valid, please check the format", false, "error");
if (!desiredName) return tip("Please provide a preset name", false, "error");
if (styleSaverTip.innerHTML === "default")
return tip("You cannot overwrite default preset, please change the name", false, "error");
const presetName = customPresetPrefix + desiredName;
applyOption(stylePreset, presetName, desiredName + " [custom]");
localStorage.setItem("presetStyle", presetName);
localStorage.setItem(presetName, styleJSON);
applyStyleWithUiRefresh(JSON.parse(styleJSON));
tip("Style preset is saved and applied", false, "success", 4000);
$("#styleSaver").dialog("close");
}
function styleDownload() {
const styleJSON = styleSaverJSON.value;
const styleName = styleSaverName.value;
if (!styleJSON) return tip("Please provide a style JSON", false, "error");
if (!isJsonValid(styleJSON)) return tip("JSON string is not valid, please check the format", false, "error");
if (!styleName) return tip("Please provide a preset name", false, "error");
downloadFile(styleJSON, styleName + ".json", "application/json");
}
function loadStyleFile() {
const fileName = this.files[0]?.name.replace(/\.[^.]*$/, "");
uploadFile(this, styleUpload);
function styleUpload(dataLoaded) {
if (!dataLoaded) return tip("Cannot load the file. Please check the data format", false, "error");
const isValid = isJsonValid(dataLoaded);
if (!isValid) return tip("Loaded data is not a valid JSON, please check the format", false, "error");
styleSaverJSON.value = JSON.stringify(JSON.parse(dataLoaded), null, 2);
styleSaverName.value = fileName;
checkName();
tip("Style preset is uploaded", false, "success", 4000);
}
}
}
function requestRemoveStylePreset() {
const isDefault = systemPresets.includes(stylePreset.value);
if (isDefault) return tip("Cannot remove system preset", false, "error");
confirmationDialog({
title: "Remove style preset",
message: "Are you sure you want to remove the style preset? This action cannot be undone.",
confirm: "Remove",
onConfirm: removeStylePreset
});
}
function removeStylePreset() {
localStorage.removeItem("presetStyle");
localStorage.removeItem(stylePreset.value);
stylePreset.selectedOptions[0].remove();
changeStyle("default");
}
function updateMapFilter() {
const filter = svg.attr("data-filter");
mapFilters.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
if (!filter) return;
mapFilters.querySelector("#" + filter).classList.add("pressed");
}
function setPresetRemoveButtonVisibiliy() {
const isDefault = systemPresets.includes(stylePreset.value);
removeStyleButton.style.display = isDefault ? "none" : "inline-block";
}

360
src/modules/ui/submap.js Normal file
View file

@ -0,0 +1,360 @@
import {byId} from "/src/utils/shorthands";
import {clearMainTip} from "/src/scripts/tooltips";
import {parseError} from "/src/utils/errorUtils";
import {rn, minmax} from "/src/utils/numberUtils";
import {debounce} from "/src/utils/functionUtils";
window.UISubmap = (function () {
byId("submapPointsInput").addEventListener("input", function () {
const output = byId("submapPointsOutputFormatted");
const cells = cellsDensityMap[+this.value] || 1000;
this.dataset.cells = cells;
output.value = getCellsDensityValue(cells);
output.style.color = getCellsDensityColor(cells);
});
byId("submapScaleInput").addEventListener("input", function (event) {
const exp = Math.pow(1.1, +event.target.value);
byId("submapScaleOutput").value = rn(exp, 2);
});
byId("submapAngleInput").addEventListener("input", function (event) {
byId("submapAngleOutput").value = event.target.value;
});
const $previewBox = byId("submapPreview");
const $scaleInput = byId("submapScaleInput");
const $shiftX = byId("submapShiftX");
const $shiftY = byId("submapShiftY");
function openSubmapMenu() {
$("#submapOptionsDialog").dialog({
title: "Create a submap",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Submap: function () {
$(this).dialog("close");
generateSubmap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
const getTransformInput = _ => ({
angle: (+byId("submapAngleInput").value / 180) * Math.PI,
shiftX: +byId("submapShiftX").value,
shiftY: +byId("submapShiftY").value,
ratio: +byId("submapScaleInput").value,
mirrorH: byId("submapMirrorH").checked,
mirrorV: byId("submapMirrorV").checked
});
async function openResampleMenu() {
resetZoom(0);
byId("submapAngleInput").value = 0;
byId("submapAngleOutput").value = "0";
byId("submapScaleOutput").value = 1;
byId("submapMirrorH").checked = false;
byId("submapMirrorV").checked = false;
$scaleInput.value = 0;
$shiftX.value = 0;
$shiftY.value = 0;
const w = Math.min(400, window.innerWidth * 0.5);
const previewScale = w / graphWidth;
const h = graphHeight * previewScale;
$previewBox.style.width = w + "px";
$previewBox.style.height = h + "px";
// handle mouse input
const dispatchInput = e => e.dispatchEvent(new Event("input", {bubbles: true}));
// mouse wheel
$previewBox.onwheel = e => {
$scaleInput.value = $scaleInput.valueAsNumber - Math.sign(e.deltaY);
dispatchInput($scaleInput);
};
// mouse drag
let mouseIsDown = false,
mouseX = 0,
mouseY = 0;
$previewBox.onmousedown = e => {
mouseIsDown = true;
mouseX = $shiftX.value - e.clientX / previewScale;
mouseY = $shiftY.value - e.clientY / previewScale;
};
$previewBox.onmouseup = _ => (mouseIsDown = false);
$previewBox.onmouseleave = _ => (mouseIsDown = false);
$previewBox.onmousemove = e => {
if (!mouseIsDown) return;
e.preventDefault();
$shiftX.value = Math.round(mouseX + e.clientX / previewScale);
$shiftY.value = Math.round(mouseY + e.clientY / previewScale);
dispatchInput($shiftX);
// dispatchInput($shiftY); // not needed X bubbles anyway
};
$("#resampleDialog").dialog({
title: "Transform map",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Transform: function () {
$(this).dialog("close");
resampleCurrentMap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
// use double resolution for PNG to get sharper image
const $preview = await loadPreview($previewBox, w * 2, h * 2);
// could be done with SVG. Faster to load, slower to use.
// const $preview = await loadPreviewSVG($previewBox, w, h);
$preview.style.position = "absolute";
$preview.style.width = w + "px";
$preview.style.height = h + "px";
byId("resampleDialog").oninput = event => {
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const scale = Math.pow(1.1, ratio);
const transformStyle = `
translate(${shiftX * previewScale}px, ${shiftY * previewScale}px)
scale(${mirrorH ? -scale : scale}, ${mirrorV ? -scale : scale})
rotate(${angle}rad)
`;
$preview.style.transform = transformStyle;
$preview.style["transform-origin"] = "center";
event.stopPropagation();
};
}
async function loadPreview($container, w, h) {
const url = await getMapURL("png", {
globe: false,
noWater: true,
fullMap: true,
noLabels: true,
noScaleBar: true,
noIce: true
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = w;
canvas.height = h;
const img = new Image();
img.src = url;
img.onload = function () {
ctx.drawImage(img, 0, 0, w, h);
};
$container.textContent = "";
$container.appendChild(canvas);
return canvas;
}
// currently unused alternative to PNG version
async function loadPreviewSVG($container, w, h) {
$container.innerHTML = /*html*/ `
<svg id="submapPreviewSVG" viewBox="0 0 ${graphWidth} ${graphHeight}">
<rect width="100%" height="100%" fill="${byId("styleOceanFill").value}" />
<rect fill="url(#oceanic)" width="100%" height="100%" />
<use href="#map"></use>
</svg>
`;
return byId("submapPreviewSVG");
}
// Resample the whole map to different cell resolution or shape
const resampleCurrentMap = debounce(function () {
WARN && console.warn("Resampling current map");
const cellNumId = +byId("submapPointsInput").value;
if (!cellsDensityMap[cellNumId]) return console.error("Unknown cell number!");
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const [cx, cy] = [graphWidth / 2, graphHeight / 2];
const rot = alfa => (x, y) =>
[
(x - cx) * Math.cos(alfa) - (y - cy) * Math.sin(alfa) + cx,
(y - cy) * Math.cos(alfa) + (x - cx) * Math.sin(alfa) + cy
];
const shift = (dx, dy) => (x, y) => [x + dx, y + dy];
const scale = r => (x, y) => [(x - cx) * r + cx, (y - cy) * r + cy];
const flipH = (x, y) => [-x + 2 * cx, y];
const flipV = (x, y) => [x, -y + 2 * cy];
const app = (f, g) => (x, y) => f(...g(x, y));
const id = (x, y) => [x, y];
let projection = id;
let inverse = id;
if (angle) [projection, inverse] = [rot(angle), rot(-angle)];
if (ratio)
[projection, inverse] = [
app(scale(Math.pow(1.1, ratio)), projection),
app(inverse, scale(Math.pow(1.1, -ratio)))
];
if (mirrorH) [projection, inverse] = [app(flipH, projection), app(inverse, flipH)];
if (mirrorV) [projection, inverse] = [app(flipV, projection), app(inverse, flipV)];
if (shiftX || shiftY) {
projection = app(shift(shiftX, shiftY), projection);
inverse = app(inverse, shift(-shiftX, -shiftY));
}
changeCellsDensity(cellNumId);
startResample({
lockMarkers: false,
lockBurgs: false,
depressRivers: false,
addLakesInDepressions: false,
promoteTowns: false,
smoothHeightMap: false,
rescaleStyles: false,
scale: 1,
projection,
inverse
});
}, 1000);
// calculate x y extreme points of viewBox
function getViewBoxExtent() {
return [
[Math.abs(viewX / scale), Math.abs(viewY / scale)],
[Math.abs(viewX / scale) + graphWidth / scale, Math.abs(viewY / scale) + graphHeight / scale]
];
}
// Create submap from the current map. Submap limits defined by the current window size (canvas viewport)
const generateSubmap = debounce(function () {
WARN && console.warn("Resampling current map");
closeDialogs("#worldConfigurator, #options3d");
const checked = id => Boolean(byId(id).checked);
// Create projection func from current zoom extents
const [[x0, y0], [x1, y1]] = getViewBoxExtent();
const origScale = scale;
const options = {
lockMarkers: checked("submapLockMarkers"),
lockBurgs: checked("submapLockBurgs"),
depressRivers: checked("submapDepressRivers"),
addLakesInDepressions: checked("submapAddLakeInDepression"),
promoteTowns: checked("submapPromoteTowns"),
rescaleStyles: checked("submapRescaleStyles"),
smoothHeightMap: scale > 2,
inverse: (x, y) => [x / origScale + x0, y / origScale + y0],
projection: (x, y) => [(x - x0) * origScale, (y - y0) * origScale],
scale: origScale
};
// converting map position on the planet
const mapSizeOutput = byId("mapSizeOutput");
const latitudeOutput = byId("latitudeOutput");
const latN = 90 - ((180 - (mapSizeInput.value / 100) * 180) * latitudeOutput.value) / 100;
const newLatN = latN - ((y0 / graphHeight) * mapSizeOutput.value * 180) / 100;
mapSizeOutput.value /= scale;
latitudeOutput.value = ((90 - newLatN) / (180 - (mapSizeOutput.value / 100) * 180)) * 100;
byId("mapSizeInput").value = mapSizeOutput.value;
byId("latitudeInput").value = latitudeOutput.value;
// fix scale
distanceScaleInput.value = distanceScaleOutput.value = rn((distanceScale = distanceScaleOutput.value / scale), 2);
populationRateInput.value = populationRateOutput.value = rn(
(populationRate = populationRateOutput.value / scale),
2
);
customization = 0;
startResample(options);
}, 1000);
async function startResample(options) {
// Do model changes with Submap.resample then do view changes if needed
resetZoom(0);
let oldstate = {
grid: structuredClone(grid),
pack: structuredClone(pack),
notes: structuredClone(notes),
seed,
graphWidth,
graphHeight
};
undraw();
try {
const oldScale = scale;
await Submap.resample(oldstate, options);
if (options.promoteTowns) {
const groupName = "largetowns";
moveAllBurgsToGroup("towns", groupName);
changeRadius(rn(oldScale * 0.8, 2), groupName);
changeFontSize(svg.select(`#labels #${groupName}`), rn(oldScale * 2, 2));
invokeActiveZooming();
}
if (options.rescaleStyles) changeStyles(oldScale);
} catch (error) {
showSubmapErrorHandler(error);
}
oldstate = null; // destroy old state to free memory
restoreLayers();
if (ThreeD.options.isOn) ThreeD.redraw();
if ($("#worldConfigurator").is(":visible")) editWorld();
}
function changeStyles(scale) {
// resize burgIcons
const burgIcons = [...byId("burgIcons").querySelectorAll("g")];
for (const bi of burgIcons) {
const newRadius = rn(minmax(bi.getAttribute("size") * scale, 0.2, 10), 2);
changeRadius(newRadius, bi.id);
const swAttr = bi.attributes["stroke-width"];
swAttr.value = +swAttr.value * scale;
}
// burglabels
const burgLabels = [...byId("burgLabels").querySelectorAll("g")];
for (const bl of burgLabels) {
const size = +bl.dataset["size"];
bl.dataset["size"] = Math.max(rn((size + size / scale) / 2, 2), 1) * scale;
}
// emblems
const emblemMod = minmax((scale - 1) * 0.3 + 1, 0.5, 5);
emblemsStateSizeInput.value = minmax(+emblemsStateSizeInput.value * emblemMod, 0.5, 5);
emblemsProvinceSizeInput.value = minmax(+emblemsProvinceSizeInput.value * emblemMod, 0.5, 5);
emblemsBurgSizeInput.value = minmax(+emblemsBurgSizeInput.value * emblemMod, 0.5, 5);
drawEmblems();
}
function showSubmapErrorHandler(error) {
ERROR && console.error(error);
clearMainTip();
alertMessage.innerHTML = /* html */ `Map resampling failed: <br />You may retry after clearing stored data or contact us at discord.
<p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Resampling error",
width: "32em",
buttons: {
Ok: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
return {openSubmapMenu, openResampleMenu};
})();

View file

@ -0,0 +1,214 @@
import {tip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {round} from "/src/utils/stringUtils";
import {convertTemperature} from "/src/utils/unitUtils";
export function showBurgTemperatureGraph(id) {
const b = pack.burgs[id];
const lat = mapCoordinates.latN - (b.y / graphHeight) * mapCoordinates.latT;
const burgTemp = grid.cells.temp[pack.cells.g[b.cell]];
const prec = grid.cells.prec[pack.cells.g[b.cell]];
// prettier-ignore
const weights = [
[
[10.782752257744338, 2.7100404240962126], [-2.8226802110591462, 51.62920138583541], [-6.6250956268643835, 4.427939197315455], [-59.64690518541339, 41.89084162654791], [-1.3302059550553835, -3.6964487738450913],
[-2.5844898544535497, 0.09879268612455298], [-5.58528252533573, -0.23426224364501905], [26.94531337690372, 20.898158905988907], [3.816397481634785, -0.19045424064580757], [-4.835697931609101, -10.748232783636434]
],
[
[-2.478952081870123, 0.6405800134306895, -7.136785640930911, -0.2186529024764509, 3.6568435212735424, 31.446026153530838, -19.91005187482281, 0.2543395274783306, -7.036924569659988, -0.7721371621651565],
[-197.10583739743538, 6.889921141533474, 0.5058941504631129, 7.7667203434606416, -53.74180550086929, -15.717331715167001, -61.32068414155791, -2.259728220978728, 35.84049189540032, 94.6157364730977],
[-5.312011591880851, -0.09923148954215096, -1.7132477487917586, -22.55559652066422, 0.4806107280554336, -26.5583974109492, 2.0558257347014863, 25.815645234787432, -18.569029876991156, -2.6792003366730035],
[20.706518520569514, 18.344297403881875, 99.52244671131733, -58.53124969563653, -60.74384321042212, -80.57540534651835, 7.884792406540866, -144.33871131678563, 80.134199744324, 20.50745285622448],
[-52.88299538575159, -15.782505343805528, 16.63316001054924, 88.09475330556671, -17.619552086641818, -19.943999528182427, -120.46286026828177, 19.354752020806302, 43.49422099308949, 28.733924806541363],
[-2.4621368711159897, -1.2074759925679757, -1.5133898639835084, 2.173715352424188, -5.988707597991683, 3.0234147182203843, 3.3284199340000797, -1.8805161326360575, 5.151910934121654, -1.2540553911612116]
],
[
[-0.3357437479474717, 0.01430651794222215, -0.7927524256670906, 0.2121636229648523, 1.0587803023358318, -3.759288325505095],
[-1.1988028704442968, 1.3768997508052783, -3.8480086358278816, 0.5289387340947143, 0.5769459339961177, -1.2528318145750772],
[1.0074966649240946, 1.155301164699459, -2.974254371052421, 0.47408176553219467, 0.5939042688615264, -0.7631976947131744]
]
];
// From (-∞, ∞) to ~[-1, 1]
const In1 = [(Math.abs(lat) - 26.950680212887473) / 48.378128506956, (prec - 12.229929140832644) / 29.94402033696607];
let lastIn = In1;
let lstOut = [];
for (let levelN = 0; levelN < weights.length; levelN++) {
const layerN = weights[levelN];
for (let i = 0; i < layerN.length; i++) {
lstOut[i] = 0;
for (let j = 0; j < layerN[i].length; j++) {
lstOut[i] = lstOut[i] + lastIn[j] * layerN[i][j];
}
// sigmoid
lstOut[i] = 1 / (1 + Math.exp(-lstOut[i]));
}
lastIn = lstOut.slice(0);
}
// Standard deviation for average temperature for the year from [0, 1] to [min, max]
const yearSig = lstOut[0] * 62.9466411977018 + 0.28613807855649165;
// Standard deviation for the difference between the minimum and maximum temperatures for the year
const yearDelTmpSig =
lstOut[1] * 13.541688670361175 + 0.1414213562373084 > yearSig
? yearSig
: lstOut[1] * 13.541688670361175 + 0.1414213562373084;
// Expected value for the difference between the minimum and maximum temperatures for the year
const yearDelTmpMu = lstOut[2] * 15.266666666666667 + 0.6416666666666663;
// Temperature change shape
const delT = yearDelTmpMu / 2 + (0.5 * yearDelTmpSig) / 2;
const minT = burgTemp - Math.max(yearSig + delT, 15);
const maxT = burgTemp + (burgTemp - minT);
const chartWidth = Math.max(window.innerWidth / 2, 580);
const chartHeight = 300;
// drawing starting point from top-left (y = 0) of SVG
const xOffset = 60;
const yOffset = 10;
const year = new Date().getFullYear(); // use current year
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
const xscale = d3.scaleTime().domain([startDate, endDate]).range([0, chartWidth]);
const yscale = d3.scaleLinear().domain([minT, maxT]).range([chartHeight, 0]);
const tempMean = [];
const tempMin = [];
const tempMax = [];
months.forEach((month, index) => {
const rate = index / 11;
let formTmp = Math.cos(rate * 2 * Math.PI) / 2;
if (lat > 0) formTmp = -formTmp;
const x = rate * chartWidth + xOffset;
const tempAverage = formTmp * yearSig + burgTemp;
const tempDelta = yearDelTmpMu / 2 + (formTmp * yearDelTmpSig) / 2;
tempMean.push([x, yscale(tempAverage) + yOffset]);
tempMin.push([x, yscale(tempAverage - tempDelta) + yOffset]);
tempMax.push([x, yscale(tempAverage + tempDelta) + yOffset]);
});
drawGraph();
$("#alert").dialog({
title: "Annual temperature in " + b.name,
width: "auto",
position: {my: "center", at: "center", of: "svg"}
});
function drawGraph() {
alertMessage.innerHTML = "";
const getCurve = data => round(d3.line().curve(d3.curveBasis)(data), 2);
const legendSize = 60;
const chart = d3
.select("#alertMessage")
.append("svg")
.attr("width", chartWidth + 120)
.attr("height", chartHeight + yOffset + legendSize);
const legend = chart.append("g");
const legendY = chartHeight + yOffset + legendSize * 0.8;
const legendX = n => (chartWidth * n) / 4;
const legendTextX = n => legendX(n) + 10;
legend.append("circle").attr("cx", legendX(1)).attr("cy", legendY).attr("r", 4).style("fill", "red");
legend
.append("text")
.attr("x", legendTextX(1))
.attr("y", legendY)
.attr("alignment-baseline", "central")
.text("Day temperature");
legend.append("circle").attr("cx", legendX(2)).attr("cy", legendY).attr("r", 4).style("fill", "orange");
legend
.append("text")
.attr("x", legendTextX(2))
.attr("y", legendY)
.attr("alignment-baseline", "central")
.text("Mean temperature");
legend.append("circle").attr("cx", legendX(3)).attr("cy", legendY).attr("r", 4).style("fill", "blue");
legend
.append("text")
.attr("x", legendTextX(3))
.attr("y", legendY)
.attr("alignment-baseline", "central")
.text("Night temperature");
const xGrid = d3.axisBottom(xscale).ticks().tickSize(-chartHeight);
const yGrid = d3.axisLeft(yscale).ticks(5).tickSize(-chartWidth);
const grid = chart.append("g").attr("class", "epgrid").attr("stroke-dasharray", "4 1");
grid.append("g").attr("transform", `translate(${xOffset}, ${chartHeight + yOffset})`).call(xGrid); // prettier-ignore
grid.append("g").attr("transform", `translate(${xOffset}, ${yOffset})`).call(yGrid);
grid.selectAll("text").remove();
// add zero degree line
if (minT < 0 && maxT > 0) {
grid
.append("line")
.attr("x1", xOffset)
.attr("y1", yscale(0) + yOffset)
.attr("x2", chartWidth + xOffset)
.attr("y2", yscale(0) + yOffset)
.attr("stroke", "gray");
}
const xAxis = d3.axisBottom(xscale).ticks().tickFormat(d3.timeFormat("%B"));
const yAxis = d3.axisLeft(yscale).ticks(5).tickFormat(convertTemperature);
const axis = chart.append("g");
axis
.append("g")
.attr("transform", `translate(${xOffset}, ${chartHeight + yOffset})`)
.call(xAxis);
axis.append("g").attr("transform", `translate(${xOffset}, ${yOffset})`).call(yAxis);
axis.select("path.domain").attr("d", `M0.5,0.5 H${chartWidth + 0.5}`);
const curves = chart.append("g").attr("fill", "none").style("stroke-width", 2.5);
curves
.append("path")
.attr("d", getCurve(tempMean))
.attr("data-type", "daily")
.attr("stroke", "orange")
.on("mousemove", printVal);
curves
.append("path")
.attr("d", getCurve(tempMin))
.attr("data-type", "night")
.attr("stroke", "blue")
.on("mousemove", printVal);
curves
.append("path")
.attr("d", getCurve(tempMax))
.attr("data-type", "day")
.attr("stroke", "red")
.on("mousemove", printVal);
function printVal() {
const [x, y] = d3.mouse(this);
const type = this.getAttribute("data-type");
const temp = convertTemperature(yscale.invert(y - yOffset));
const month = months[rn(((x - xOffset) / chartWidth) * 12)] || months[0];
tip(`Average ${type} temperature in ${month}: ${temp}`);
}
}
}

870
src/modules/ui/tools.js Normal file
View file

@ -0,0 +1,870 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {last} from "/src/utils/arrayUtils";
import {tip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {isCtrlClick} from "/src/utils/keyboardUtils";
import {prompt} from "/src/scripts/prompt";
import {getNextId} from "/src/utils/nodeUtils";
import {P, generateSeed} from "/src/utils/probabilityUtils";
toolsContent.addEventListener("click", function (event) {
if (customization) return tip("Please exit the customization mode first", false, "warning");
if (!["BUTTON", "I"].includes(event.target.tagName)) return;
const button = event.target.id;
// click on open Editor buttons
if (button === "editHeightmapButton") editHeightmap();
else if (button === "editBiomesButton") editBiomes();
else if (button === "editStatesButton") editStates();
else if (button === "editProvincesButton") editProvinces();
else if (button === "editDiplomacyButton") editDiplomacy();
else if (button === "editCulturesButton") editCultures();
else if (button === "editReligions") editReligions();
else if (button === "editEmblemButton") openEmblemEditor();
else if (button === "editNamesBaseButton") editNamesbase();
else if (button === "editUnitsButton") editUnits();
else if (button === "editNotesButton") editNotes();
else if (button === "editZonesButton") editZones();
else if (button === "overviewChartsButton") overviewCharts();
else if (button === "overviewBurgsButton") overviewBurgs();
else if (button === "overviewRiversButton") overviewRivers();
else if (button === "overviewMilitaryButton") overviewMilitary();
else if (button === "overviewMarkersButton") overviewMarkers();
else if (button === "overviewCellsButton") viewCellDetails();
// click on Regenerate buttons
if (event.target.parentNode.id === "regenerateFeature") {
const dontAsk = sessionStorage.getItem("regenerateFeatureDontAsk");
if (dontAsk) return processFeatureRegeneration(event, button);
alertMessage.innerHTML = /* html */ `Regeneration will remove all the custom changes for the element.<br /><br />Are you sure you want to proceed?`;
$("#alert").dialog({
resizable: false,
title: "Regenerate element",
buttons: {
Proceed: function () {
processFeatureRegeneration(event, button);
$(this).dialog("close");
},
Cancel: function () {
$(this).dialog("close");
}
},
open: function () {
const checkbox =
'<span><input id="dontAsk" class="checkbox" type="checkbox"><label for="dontAsk" class="checkbox-label dontAsk"><i>do not ask again</i></label><span>';
const pane = this.parentElement.querySelector(".ui-dialog-buttonpane");
pane.insertAdjacentHTML("afterbegin", checkbox);
},
close: function () {
const box = this.parentElement.querySelector(".checkbox");
if (box?.checked) sessionStorage.setItem("regenerateFeatureDontAsk", true);
$(this).dialog("destroy");
}
});
}
// click on Configure regenerate buttons
if (button === "configRegenerateMarkers") configMarkersGeneration();
// click on Add buttons
if (button === "addLabel") toggleAddLabel();
else if (button === "addBurgTool") toggleAddBurg();
else if (button === "addRiver") toggleAddRiver();
else if (button === "addRoute") toggleAddRoute();
else if (button === "addMarker") toggleAddMarker();
// click to create a new map buttons
else if (button === "openSubmapMenu") UISubmap.openSubmapMenu();
else if (button === "openResampleMenu") UISubmap.openResampleMenu();
});
function processFeatureRegeneration(event, button) {
if (button === "regenerateStateLabels") {
BurgsAndStates.drawStateLabels();
if (!layerIsOn("toggleLabels")) toggleLabels();
} else if (button === "regenerateReliefIcons") {
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
} else if (button === "regenerateRoutes") {
Routes.regenerate();
if (!layerIsOn("toggleRoutes")) toggleRoutes();
} else if (button === "regenerateRivers") regenerateRivers();
else if (button === "regeneratePopulation") recalculatePopulation();
else if (button === "regenerateStates") regenerateStates();
else if (button === "regenerateProvinces") regenerateProvinces();
else if (button === "regenerateBurgs") regenerateBurgs();
else if (button === "regenerateEmblems") regenerateEmblems();
else if (button === "regenerateReligions") regenerateReligions();
else if (button === "regenerateCultures") regenerateCultures();
else if (button === "regenerateMilitary") regenerateMilitary();
else if (button === "regenerateIce") regenerateIce();
else if (button === "regenerateMarkers") regenerateMarkers();
else if (button === "regenerateZones") regenerateZones(event);
}
async function openEmblemEditor() {
let type, id, el;
if (pack.states[1]?.coa) {
type = "state";
id = "stateCOA1";
el = pack.states[1];
} else if (pack.burgs[1]?.coa) {
type = "burg";
id = "burgCOA1";
el = pack.burgs[1];
} else {
tip("No emblems to edit, please generate states and burgs first", false, "error");
return;
}
await COArenderer.trigger(id, el.coa);
editEmblem(type, id, el);
}
function regenerateRivers() {
Rivers.generate();
Lakes.defineGroup();
Rivers.specify();
if (!layerIsOn("toggleRivers")) toggleRivers();
else drawRivers();
}
function recalculatePopulation() {
rankCells();
pack.burgs.forEach(b => {
if (!b.i || b.removed || b.lock) return;
const i = b.cell;
b.population = rn(Math.max((pack.cells.s[i] + pack.cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
if (b.capital) b.population = b.population * 1.3; // increase capital population
if (b.port) b.population = b.population * 1.3; // increase port population
b.population = rn(b.population * gauss(2, 3, 0.6, 20, 3), 3);
});
}
function regenerateStates() {
const localSeed = generateSeed();
Math.random = aleaPRNG(localSeed);
const statesCount = +regionsOutput.value;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
if (!burgs.length) return tip("There are no any burgs to generate states. Please create burgs first", false, "error");
if (burgs.length < statesCount)
tip(`Not enough burgs to generate ${statesCount} states. Will generate only ${burgs.length} states`, false, "warn");
// turn all old capitals into towns
burgs
.filter(b => b.capital)
.forEach(b => {
moveBurgToGroup(b.i, "towns");
b.capital = 0;
});
// remove emblems
document.querySelectorAll("[id^=stateCOA]").forEach(el => el.remove());
document.querySelectorAll("[id^=provinceCOA]").forEach(el => el.remove());
emblems.selectAll("use").remove();
unfog();
if (!statesCount) {
tip(`Cannot generate zero states. Please check the <i>States Number</i> option`, false, "warn");
pack.states = pack.states.slice(0, 1); // remove all except of neutrals
pack.states[0].diplomacy = []; // clear diplomacy
pack.provinces = [0]; // remove all provinces
pack.cells.state = new Uint16Array(pack.cells.i.length); // reset cells data
borders.selectAll("path").remove(); // remove borders
regions.selectAll("path").remove(); // remove states fill
labels.select("#states").selectAll("text"); // remove state labels
defs.select("#textPaths").selectAll("path[id*='stateLabel']").remove(); // remove state labels paths
if (document.getElementById("burgsOverviewRefresh").offsetParent) burgsOverviewRefresh.click();
if (document.getElementById("statesEditorRefresh").offsetParent) statesEditorRefresh.click();
return;
}
// burg local ids sorted by a bit randomized population:
const sortedBurgs = burgs
.map((b, i) => [b, b.population * Math.random()])
.sort((a, b) => b[1] - a[1])
.map(b => b[0]);
const capitalsTree = d3.quadtree();
const neutral = pack.states[0].name; // neutrals name
const count = Math.min(statesCount, burgs.length) + 1; // +1 for neutral
let spacing = (graphWidth + graphHeight) / 2 / count; // min distance between capitals
pack.states = d3.range(count).map(i => {
if (!i) return {i, name: neutral};
let capital = null;
for (const burg of sortedBurgs) {
const {x, y} = burg;
if (capitalsTree.find(x, y, spacing) === undefined) {
burg.capital = 1;
capital = burg;
capitalsTree.add([x, y]);
moveBurgToGroup(burg.i, "cities");
break;
}
spacing = Math.max(spacing - 1, 1);
}
const culture = capital.culture;
const basename =
capital.name.length < 9 && capital.cell % 5 === 0 ? capital.name : Names.getCulture(culture, 3, 6, "", 0);
const name = Names.getState(basename, culture);
const nomadic = [1, 2, 3, 4].includes(pack.cells.biome[capital.cell]);
const type = nomadic
? "Nomadic"
: pack.cultures[culture].type === "Nomadic"
? "Generic"
: pack.cultures[culture].type;
const expansionism = rn(Math.random() * powerInput.value + 1, 1);
const cultureType = pack.cultures[culture].type;
const coa = COA.generate(capital.coa, 0.3, null, cultureType);
coa.shield = capital.coa.shield;
return {i, name, type, capital: capital.i, center: capital.cell, culture, expansionism, coa};
});
BurgsAndStates.expandStates();
BurgsAndStates.normalizeStates();
BurgsAndStates.collectStatistics();
BurgsAndStates.assignColors();
BurgsAndStates.generateCampaigns();
BurgsAndStates.generateDiplomacy();
BurgsAndStates.defineStateForms();
BurgsAndStates.generateProvinces(true);
if (!layerIsOn("toggleStates")) toggleStates();
else drawStates();
if (!layerIsOn("toggleBorders")) toggleBorders();
else drawBorders();
BurgsAndStates.drawStateLabels();
Military.generate();
if (layerIsOn("toggleEmblems")) drawEmblems(); // redrawEmblems
if (document.getElementById("burgsOverviewRefresh")?.offsetParent) burgsOverviewRefresh.click();
if (document.getElementById("statesEditorRefresh")?.offsetParent) statesEditorRefresh.click();
if (document.getElementById("militaryOverviewRefresh")?.offsetParent) militaryOverviewRefresh.click();
}
function regenerateProvinces() {
unfog();
BurgsAndStates.generateProvinces(true);
drawBorders();
if (layerIsOn("toggleProvinces")) drawProvinces();
// remove emblems
document.querySelectorAll("[id^=provinceCOA]").forEach(el => el.remove());
emblems.selectAll("use").remove();
if (layerIsOn("toggleEmblems")) drawEmblems();
}
function regenerateBurgs() {
const {cells, states} = pack;
const lockedburgs = pack.burgs.filter(b => b.lock);
rankCells();
cells.burg = new Uint16Array(cells.i.length);
const burgs = (pack.burgs = [0]); // clear burgs array
states.filter(s => s.i).forEach(s => (s.capital = 0)); // clear state capitals
pack.provinces.filter(p => p.i).forEach(p => (p.burg = 0)); // clear province capitals
const burgsTree = d3.quadtree();
// add locked burgs
for (let j = 0; j < lockedburgs.length; j++) {
const id = burgs.length;
const lockedBurg = lockedburgs[j];
lockedBurg.i = id;
burgs.push(lockedBurg);
burgsTree.add([lockedBurg.x, lockedBurg.y]);
cells.burg[lockedBurg.cell] = id;
if (lockedBurg.capital) {
const stateId = lockedBurg.state;
states[stateId].capital = id;
states[stateId].center = lockedBurg.cell;
}
}
const score = new Int16Array(cells.s.map(s => s * Math.random())); // cell score for capitals placement
const sorted = cells.i.filter(i => score[i] > 0 && cells.culture[i]).sort((a, b) => score[b] - score[a]); // filtered and sorted array of indexes
const burgsCount =
manorsInput.value === "1000"
? rn(sorted.length / 5 / (grid.points.length / 10000) ** 0.8) + states.length
: +manorsInput.value + states.length;
const spacing = (graphWidth + graphHeight) / 150 / (burgsCount ** 0.7 / 66); // base min distance between towns
for (let i = 0; i < sorted.length && burgs.length < burgsCount; i++) {
const id = burgs.length;
const cell = sorted[i];
const [x, y] = cells.p[cell];
const s = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make the placement not uniform
if (burgsTree.find(x, y, s) !== undefined) continue; // to close to existing burg
const stateId = cells.state[cell];
const capital = stateId && !states[stateId].capital; // if state doesn't have capital, make this burg a capital, no capital for neutral lands
if (capital) {
states[stateId].capital = id;
states[stateId].center = cell;
}
const culture = cells.culture[cell];
const name = Names.getCulture(culture);
burgs.push({cell, x, y, state: stateId, i: id, culture, name, capital, feature: cells.f[cell]});
burgsTree.add([x, y]);
cells.burg[cell] = id;
}
// add a capital at former place for states without added capitals
states
.filter(s => s.i && !s.removed && !s.capital)
.forEach(s => {
const burg = addBurg([cells.p[s.center][0], cells.p[s.center][1]]); // add new burg
s.capital = burg;
s.center = pack.burgs[burg].cell;
pack.burgs[burg].capital = 1;
pack.burgs[burg].state = s.i;
moveBurgToGroup(burg, "cities");
});
pack.features.forEach(f => {
if (f.port) f.port = 0; // reset features ports counter
});
BurgsAndStates.specifyBurgs();
BurgsAndStates.defineBurgFeatures();
BurgsAndStates.drawBurgs();
Routes.regenerate();
// remove emblems
document.querySelectorAll("[id^=burgCOA]").forEach(el => el.remove());
emblems.selectAll("use").remove();
if (layerIsOn("toggleEmblems")) drawEmblems();
if (document.getElementById("burgsOverviewRefresh")?.offsetParent) burgsOverviewRefresh.click();
if (document.getElementById("statesEditorRefresh")?.offsetParent) statesEditorRefresh.click();
}
function regenerateEmblems() {
// remove old emblems
document.querySelectorAll("[id^=stateCOA]").forEach(el => el.remove());
document.querySelectorAll("[id^=provinceCOA]").forEach(el => el.remove());
document.querySelectorAll("[id^=burgCOA]").forEach(el => el.remove());
emblems.selectAll("use").remove();
// generate new emblems
pack.states.forEach(state => {
if (!state.i || state.removed) return;
const cultureType = pack.cultures[state.culture].type;
state.coa = COA.generate(null, null, null, cultureType);
state.coa.shield = COA.getShield(state.culture, null);
});
pack.burgs.forEach(burg => {
if (!burg.i || burg.removed) return;
const state = pack.states[burg.state];
let kinship = state ? 0.25 : 0;
if (burg.capital) kinship += 0.1;
else if (burg.port) kinship -= 0.1;
if (state && burg.culture !== state.culture) kinship -= 0.25;
burg.coa = COA.generate(state ? state.coa : null, kinship, null, burg.type);
burg.coa.shield = COA.getShield(burg.culture, state ? burg.state : 0);
});
pack.provinces.forEach(province => {
if (!province.i || province.removed) return;
const parent = province.burg ? pack.burgs[province.burg] : pack.states[province.state];
let dominion = false;
if (!province.burg) {
dominion = P(0.2);
if (province.formName === "Colony") dominion = P(0.95);
else if (province.formName === "Island") dominion = P(0.6);
else if (province.formName === "Islands") dominion = P(0.5);
else if (province.formName === "Territory") dominion = P(0.4);
else if (province.formName === "Land") dominion = P(0.3);
}
const nameByBurg = province.burg && province.name.slice(0, 3) === parent.name.slice(0, 3);
const kinship = dominion ? 0 : nameByBurg ? 0.8 : 0.4;
const culture = pack.cells.culture[province.center];
const type = BurgsAndStates.getType(province.center, parent.port);
province.coa = COA.generate(parent.coa, kinship, dominion, type);
province.coa.shield = COA.getShield(culture, province.state);
});
if (layerIsOn("toggleEmblems")) drawEmblems(); // redrawEmblems
}
function regenerateReligions() {
Religions.generate();
if (!layerIsOn("toggleReligions")) toggleReligions();
else drawReligions();
}
function regenerateCultures() {
Cultures.generate();
Cultures.expand();
BurgsAndStates.updateCultures();
Religions.updateCultures();
if (!layerIsOn("toggleCultures")) toggleCultures();
else drawCultures();
refreshAllEditors();
}
function regenerateMilitary() {
Military.generate();
if (!layerIsOn("toggleMilitary")) toggleMilitary();
if (document.getElementById("militaryOverviewRefresh").offsetParent) militaryOverviewRefresh.click();
}
function regenerateIce() {
if (!layerIsOn("toggleIce")) toggleIce();
ice.selectAll("*").remove();
drawIce();
}
function regenerateMarkers() {
Markers.regenerate();
turnButtonOn("toggleMarkers");
drawMarkers();
if (document.getElementById("markersOverviewRefresh").offsetParent) markersOverviewRefresh.click();
}
function regenerateZones(event) {
if (isCtrlClick(event))
prompt("Please provide zones number multiplier", {default: 1, step: 0.01, min: 0, max: 100}, v =>
addNumberOfZones(v)
);
else addNumberOfZones(gauss(1, 0.5, 0.6, 5, 2));
function addNumberOfZones(number) {
zones.selectAll("g").remove(); // remove existing zones
addZones(number);
if (document.getElementById("zonesEditorRefresh").offsetParent) zonesEditorRefresh.click();
if (!layerIsOn("toggleZones")) toggleZones();
}
}
function unpressClickToAddButton() {
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
restoreDefaultEvents();
clearMainTip();
}
function toggleAddLabel() {
const pressed = document.getElementById("addLabel").classList.contains("pressed");
if (pressed) {
unpressClickToAddButton();
return;
}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addLabel.classList.add("pressed");
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addLabelOnClick);
tip("Click on map to place label. Hold Shift to add multiple", true);
if (!layerIsOn("toggleLabels")) toggleLabels();
}
function addLabelOnClick() {
const point = d3.mouse(this);
// get culture in clicked point to generate a name
const cell = findCell(point[0], point[1]);
const culture = pack.cells.culture[cell];
const name = Names.getCulture(culture);
const id = getNextId("label");
// use most recently selected label group
const lastSelected = labelGroupSelect.value;
const groupId = ["", "states", "burgLabels"].includes(lastSelected) ? "#addedLabels" : "#" + lastSelected;
let group = labels.select(groupId);
if (!group.size())
group = labels
.append("g")
.attr("id", "addedLabels")
.attr("fill", "#3e3e4b")
.attr("opacity", 1)
.attr("stroke", "#3a3a3a")
.attr("stroke-width", 0)
.attr("font-family", "Almendra SC")
.attr("font-size", 18)
.attr("data-size", 18)
.attr("filter", null);
const example = group.append("text").attr("x", 0).attr("x", 0).text(name);
const width = example.node().getBBox().width;
const x = width / -2; // x offset;
example.remove();
group.classed("hidden", false);
group
.append("text")
.attr("id", id)
.append("textPath")
.attr("xlink:href", "#textPath_" + id)
.attr("startOffset", "50%")
.attr("font-size", "100%")
.append("tspan")
.attr("x", x)
.text(name);
defs
.select("#textPaths")
.append("path")
.attr("id", "textPath_" + id)
.attr("d", `M${point[0] - width},${point[1]} h${width * 2}`);
if (d3.event.shiftKey === false) unpressClickToAddButton();
}
function toggleAddBurg() {
unpressClickToAddButton();
document.getElementById("addBurgTool").classList.add("pressed");
overviewBurgs();
document.getElementById("addNewBurg").click();
}
function toggleAddRiver() {
const pressed = document.getElementById("addRiver").classList.contains("pressed");
if (pressed) {
unpressClickToAddButton();
document.getElementById("addNewRiver").classList.remove("pressed");
return;
}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addRiver.classList.add("pressed");
document.getElementById("addNewRiver").classList.add("pressed");
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addRiverOnClick);
tip("Click on map to place new river or extend an existing one. Hold Shift to place multiple rivers", true, "warn");
if (!layerIsOn("toggleRivers")) toggleRivers();
}
function addRiverOnClick() {
const {cells, rivers} = pack;
let i = findCell(...d3.mouse(this));
if (cells.r[i]) return tip("There is already a river here", false, "error");
if (cells.h[i] < 20) return tip("Cannot create river in water cell", false, "error");
if (cells.b[i]) return;
const {
alterHeights,
resolveDepressions,
addMeandering,
getRiverPath,
getBasin,
getName,
getType,
getWidth,
getOffset,
getApproximateLength
} = Rivers;
const riverCells = [];
let riverId = rivers.length ? last(rivers).i + 1 : 1;
let parent = riverId;
const initialFlux = grid.cells.prec[cells.g[i]];
cells.fl[i] = initialFlux;
const h = alterHeights();
resolveDepressions(h);
while (i) {
cells.r[i] = riverId;
riverCells.push(i);
const min = cells.c[i].sort((a, b) => h[a] - h[b])[0]; // downhill cell
if (h[i] <= h[min]) return tip(`Cell ${i} is depressed, river cannot flow further`, false, "error");
// pour to water body
if (h[min] < 20) {
riverCells.push(min);
const feature = pack.features[cells.f[min]];
if (feature.type === "lake") {
if (feature.outlet) parent = feature.outlet;
feature.inlets ? feature.inlets.push(riverId) : (feature.inlets = [riverId]);
}
break;
}
// pour outside of map from border cell
if (cells.b[min]) {
cells.fl[min] += cells.fl[i];
riverCells.push(-1);
break;
}
// continue propagation if min cell has no river
if (!cells.r[min]) {
cells.fl[min] += cells.fl[i];
i = min;
continue;
}
// handle case when lowest cell already has a river
const oldRiverId = cells.r[min];
const oldRiver = rivers.find(river => river.i === oldRiverId);
const oldRiverCells = oldRiver?.cells || cells.i.filter(i => cells.r[i] === oldRiverId);
const oldRiverCellsUpper = oldRiverCells.filter(i => h[i] > h[min]);
// create new river as a tributary
if (riverCells.length <= oldRiverCellsUpper.length) {
cells.conf[min] += cells.fl[i];
riverCells.push(min);
parent = oldRiverId;
break;
}
// continue old river
document.getElementById("river" + oldRiverId)?.remove();
riverCells.forEach(i => (cells.r[i] = oldRiverId));
oldRiverCells.forEach(cell => {
if (h[cell] > h[min]) {
cells.r[cell] = 0;
cells.fl[cell] = grid.cells.prec[cells.g[cell]];
} else {
riverCells.push(cell);
cells.fl[cell] += cells.fl[i];
}
});
riverId = oldRiverId;
break;
}
const river = rivers.find(r => r.i === riverId);
const source = riverCells[0];
const mouth = riverCells[riverCells.length - 2];
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor =
river?.widthFactor || (!parent || parent === riverId ? defaultWidthFactor * 1.2 : defaultWidthFactor);
const meanderedPoints = addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor));
if (river) {
river.source = source;
river.length = length;
river.discharge = discharge;
river.width = width;
river.cells = riverCells;
} else {
const basin = getBasin(parent);
const name = getName(mouth);
const type = getType({i: riverId, length, parent});
rivers.push({
i: riverId,
source,
mouth,
discharge,
length,
width,
widthFactor,
sourceWidth: 0,
parent,
cells: riverCells,
basin,
name,
type
});
}
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = getRiverPath(meanderedPoints, widthFactor);
const id = "river" + riverId;
const riversG = viewbox.select("#rivers");
riversG.append("path").attr("id", id).attr("d", path);
if (d3.event.shiftKey === false) {
Lakes.cleanupLakeData();
unpressClickToAddButton();
document.getElementById("addNewRiver").classList.remove("pressed");
if (addNewRiver.offsetParent) riversOverviewRefresh.click();
}
}
function toggleAddRoute() {
const pressed = document.getElementById("addRoute").classList.contains("pressed");
if (pressed) {
unpressClickToAddButton();
return;
}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addRoute.classList.add("pressed");
closeDialogs(".stable");
viewbox.style("cursor", "crosshair").on("click", addRouteOnClick);
tip("Click on map to add a first control point", true);
if (!layerIsOn("toggleRoutes")) toggleRoutes();
}
function addRouteOnClick() {
unpressClickToAddButton();
const point = d3.mouse(this);
const id = getNextId("route");
elSelected = routes
.select("g")
.append("path")
.attr("id", id)
.attr("data-new", 1)
.attr("d", `M${point[0]},${point[1]}`);
editRoute(true);
}
function toggleAddMarker() {
const pressed = document.getElementById("addMarker")?.classList.contains("pressed");
if (pressed) {
unpressClickToAddButton();
return;
}
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
addMarker.classList.add("pressed");
markersAddFromOverview.classList.add("pressed");
viewbox.style("cursor", "crosshair").on("click", addMarkerOnClick);
tip("Click on map to add a marker. Hold Shift to add multiple", true);
if (!layerIsOn("toggleMarkers")) toggleMarkers();
}
function addMarkerOnClick() {
const {markers} = pack;
const point = d3.mouse(this);
const x = rn(point[0], 2);
const y = rn(point[1], 2);
// Find the current cell
const cell = findCell(point[0], point[1]);
// Find the currently selected marker to use as a base
const isMarkerSelected = markers.length && elSelected?.node()?.parentElement?.id === "markers";
const selectedMarker = isMarkerSelected ? markers.find(marker => marker.i === +elSelected.attr("id").slice(6)) : null;
const baseMarker = selectedMarker || {icon: "❓"};
const marker = Markers.add({...baseMarker, x, y, cell});
const markersElement = document.getElementById("markers");
const rescale = +markersElement.getAttribute("rescale");
markersElement.insertAdjacentHTML("beforeend", drawMarker(marker, rescale));
if (d3.event.shiftKey === false) {
document.getElementById("markerAdd").classList.remove("pressed");
document.getElementById("markersAddFromOverview").classList.remove("pressed");
unpressClickToAddButton();
}
}
function configMarkersGeneration() {
drawConfigTable();
function drawConfigTable() {
const {markers} = pack;
const config = Markers.getConfig();
const headers = `<thead style='font-weight:bold'><tr>
<td data-tip="Marker type name">Type</td>
<td data-tip="Marker icon">Icon</td>
<td data-tip="Marker number multiplier">Multiplier</td>
<td data-tip="Number of markers of that type on the current map">Number</td>
</tr></thead>`;
const lines = config.map(({type, icon, multiplier}, index) => {
const inputId = `markerIconInput${index}`;
return `<tr>
<td><input value="${type}" /></td>
<td style="position: relative">
<input id="${inputId}" style="width: 5em" value="${icon}" />
<i class="icon-edit pointer" style="position: absolute; margin:.4em 0 0 -1.4em; font-size:.85em"></i>
</td>
<td><input type="number" min="0" max="100" step="0.1" value="${multiplier}" /></td>
<td style="text-align:center">${markers.filter(marker => marker.type === type).length}</td>
</tr>`;
});
const table = `<table class="table">${headers}<tbody>${lines.join("")}</tbody></table>`;
alertMessage.innerHTML = table;
alertMessage.querySelectorAll("i").forEach(selectIconButton => {
selectIconButton.addEventListener("click", function () {
const input = this.previousElementSibling;
selectIcon(input.value, icon => (input.value = icon));
});
});
}
const applyChanges = () => {
const rows = alertMessage.querySelectorAll("tbody > tr");
const rowsData = Array.from(rows).map(row => {
const inputs = row.querySelectorAll("input");
return {
type: inputs[0].value,
icon: inputs[1].value,
multiplier: parseFloat(inputs[2].value)
};
});
const config = Markers.getConfig();
const newConfig = config.map((markerType, index) => {
const {type, icon, multiplier} = rowsData[index];
return {...markerType, type, icon, multiplier};
});
Markers.setConfig(newConfig);
};
$("#alert").dialog({
resizable: false,
title: "Markers generation settings",
position: {my: "left top", at: "left+10 top+10", of: "svg", collision: "fit"},
buttons: {
Regenerate: () => {
applyChanges();
regenerateMarkers();
drawConfigTable();
},
Close: function () {
$(this).dialog("close");
}
},
open: function () {
const buttons = $(this).dialog("widget").find(".ui-dialog-buttonset > button");
buttons[0].addEventListener("mousemove", () => tip("Apply changes and regenerate markers"));
buttons[1].addEventListener("mousemove", () => tip("Close the window"));
},
close: function () {
$(this).dialog("destroy");
}
});
}
function viewCellDetails() {
$("#cellInfo").dialog({
resizable: false,
width: "22em",
title: "Cell Details",
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?v=1.87.03");
Overview.open();
}

View file

@ -0,0 +1,301 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findCell} from "/src/utils/graphUtils";
import {tip} from "/src/scripts/tooltips";
import {prompt} from "/src/scripts/prompt";
export function editUnits() {
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

@ -0,0 +1,167 @@
import {tip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {round, parseTransform} from "/src/utils/stringUtils";
export function editWorld() {
if (customization) return;
$("#worldConfigurator").dialog({
title: "Configure World",
resizable: false,
width: "minmax(40em, 85vw)",
buttons: {
"Whole World": () => applyWorldPreset(100, 50),
Northern: () => applyWorldPreset(33, 25),
Tropical: () => applyWorldPreset(33, 50),
Southern: () => applyWorldPreset(33, 75),
"Restore Winds": restoreDefaultWinds
},
open: function () {
const buttons = $(this).dialog("widget").find(".ui-dialog-buttonset > button");
buttons[0].addEventListener("mousemove", () => tip("Click to set map size to cover the whole World"));
buttons[1].addEventListener("mousemove", () => tip("Click to set map size to cover the Northern latitudes"));
buttons[2].addEventListener("mousemove", () => tip("Click to set map size to cover the Tropical latitudes"));
buttons[3].addEventListener("mousemove", () => tip("Click to set map size to cover the Southern latitudes"));
buttons[4].addEventListener("mousemove", () => tip("Click to restore default wind directions"));
},
close: function () {
$(this).dialog("destroy");
}
});
const globe = d3.select("#globe");
const clr = d3.scaleSequential(d3.interpolateSpectral);
const tMax = 30,
tMin = -25; // temperature extremes
const projection = d3.geoOrthographic().translate([100, 100]).scale(100);
const path = d3.geoPath(projection);
updateGlobeTemperature();
updateGlobePosition();
if (fmg.modules.editWorld) return;
fmg.modules.editWorld = true;
document.getElementById("worldControls").addEventListener("input", e => updateWorld(e.target));
globe.select("#globeWindArrows").on("click", changeWind);
globe.select("#globeGraticule").attr("d", round(path(d3.geoGraticule()()))); // globe graticule
updateWindDirections();
function updateWorld(el) {
if (el) {
document.getElementById(el.dataset.stored + "Input").value = el.value;
document.getElementById(el.dataset.stored + "Output").value = el.value;
if (el.dataset.stored) lock(el.dataset.stored);
}
updateGlobeTemperature();
updateGlobePosition();
calculateTemperatures();
generatePrecipitation();
const heights = new Uint8Array(pack.cells.h);
Rivers.generate();
Lakes.defineGroup();
Rivers.specify();
pack.cells.h = new Float32Array(heights);
defineBiomes();
if (layerIsOn("toggleTemp")) drawTemp();
if (layerIsOn("togglePrec")) drawPrec();
if (layerIsOn("toggleBiomes")) drawBiomes();
if (layerIsOn("toggleCoordinates")) drawCoordinates();
if (layerIsOn("toggleRivers")) drawRivers();
if (document.getElementById("canvas3d")) setTimeout(ThreeD.update(), 500);
}
function updateGlobePosition() {
const size = +document.getElementById("mapSizeOutput").value;
const eqD = ((graphHeight / 2) * 100) / size;
calculateMapCoordinates();
const mc = mapCoordinates;
const scale = +distanceScaleInput.value;
const unit = distanceUnitInput.value;
const meridian = toKilometer(eqD * 2 * scale);
document.getElementById("mapSize").innerHTML = `${graphWidth}x${graphHeight}`;
document.getElementById("mapSizeFriendly").innerHTML = `${rn(graphWidth * scale)}x${rn(
graphHeight * scale
)} ${unit}`;
document.getElementById("meridianLength").innerHTML = rn(eqD * 2);
document.getElementById("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * scale)} ${unit}`;
document.getElementById("meridianLengthEarth").innerHTML = meridian ? " = " + rn(meridian / 200) + "%🌏" : "";
document.getElementById("mapCoordinates").innerHTML = `${lat(mc.latN)} ${Math.abs(rn(mc.lonW))}°W; ${lat(
mc.latS
)} ${rn(mc.lonE)}°E`;
function toKilometer(v) {
if (unit === "km") return v;
else if (unit === "mi") return v * 1.60934;
else if (unit === "lg") return v * 5.556;
else if (unit === "vr") return v * 1.0668;
return 0; // 0 if distanceUnitInput is a custom unit
}
// parse latitude value
function lat(lat) {
return lat > 0 ? Math.abs(rn(lat)) + "°N" : Math.abs(rn(lat)) + "°S";
}
const area = d3.geoGraticule().extent([
[mc.lonW, mc.latN],
[mc.lonE, mc.latS]
]);
globe.select("#globeArea").attr("d", round(path(area.outline()))); // map area
}
function updateGlobeTemperature() {
const tEq = +document.getElementById("temperatureEquatorOutput").value;
document.getElementById("temperatureEquatorF").innerHTML = rn((tEq * 9) / 5 + 32);
const tPole = +document.getElementById("temperaturePoleOutput").value;
document.getElementById("temperaturePoleF").innerHTML = rn((tPole * 9) / 5 + 32);
globe.selectAll(".tempGradient90").attr("stop-color", clr(1 - (tPole - tMin) / (tMax - tMin)));
globe
.selectAll(".tempGradient60")
.attr("stop-color", clr(1 - (tEq - ((tEq - tPole) * 2) / 3 - tMin) / (tMax - tMin)));
globe
.selectAll(".tempGradient30")
.attr("stop-color", clr(1 - (tEq - ((tEq - tPole) * 1) / 3 - tMin) / (tMax - tMin)));
globe.select(".tempGradient0").attr("stop-color", clr(1 - (tEq - tMin) / (tMax - tMin)));
}
function updateWindDirections() {
globe
.select("#globeWindArrows")
.selectAll("path")
.each(function (d, i) {
const tr = parseTransform(this.getAttribute("transform"));
this.setAttribute("transform", `rotate(${options.winds[i]} ${tr[1]} ${tr[2]})`);
});
}
function changeWind() {
const arrow = d3.event.target.nextElementSibling;
const tier = +arrow.dataset.tier;
options.winds[tier] = (options.winds[tier] + 45) % 360;
const tr = parseTransform(arrow.getAttribute("transform"));
arrow.setAttribute("transform", `rotate(${options.winds[tier]} ${tr[1]} ${tr[2]})`);
localStorage.setItem("winds", options.winds);
const mapTiers = d3.range(mapCoordinates.latN, mapCoordinates.latS, -30).map(c => ((90 - c) / 30) | 0);
if (mapTiers.includes(tier)) updateWorld();
}
function restoreDefaultWinds() {
const defaultWinds = [225, 45, 225, 315, 135, 315];
const mapTiers = d3.range(mapCoordinates.latN, mapCoordinates.latS, -30).map(c => ((90 - c) / 30) | 0);
const update = mapTiers.some(t => options.winds[t] != defaultWinds[t]);
options.winds = defaultWinds;
updateWindDirections();
if (update) updateWorld();
}
function applyWorldPreset(size, lat) {
document.getElementById("mapSizeInput").value = document.getElementById("mapSizeOutput").value = size;
document.getElementById("latitudeInput").value = document.getElementById("latitudeOutput").value = lat;
lock("mapSize");
lock("latitude");
updateWorld();
}
}

View file

@ -0,0 +1,518 @@
import {restoreDefaultEvents} from "/src/scripts/events";
import {findAll, findCell, getPackPolygon} from "/src/utils/graphUtils";
import {unique} from "/src/utils/arrayUtils";
import {tip, showMainTip, clearMainTip} from "/src/scripts/tooltips";
import {rn} from "/src/utils/numberUtils";
import {getNextId} from "/src/utils/nodeUtils";
import {si} from "/src/utils/unitUtils";
export function editZones() {
closeDialogs();
if (!layerIsOn("toggleZones")) toggleZones();
const body = document.getElementById("zonesBodySection");
updateFilters();
zonesEditorAddLines();
if (fmg.modules.editZones) return;
fmg.modules.editZones = true;
$("#zonesEditor").dialog({
title: "Zones Editor",
resizable: false,
width: "fit-content",
close: () => exitZonesManualAssignment("close"),
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
});
// add listeners
document.getElementById("zonesFilterType").addEventListener("click", updateFilters);
document.getElementById("zonesFilterType").addEventListener("change", filterZonesByType);
document.getElementById("zonesEditorRefresh").addEventListener("click", zonesEditorAddLines);
document.getElementById("zonesEditStyle").addEventListener("click", () => editStyle("zones"));
document.getElementById("zonesLegend").addEventListener("click", toggleLegend);
document.getElementById("zonesPercentage").addEventListener("click", togglePercentageMode);
document.getElementById("zonesManually").addEventListener("click", enterZonesManualAssignent);
document.getElementById("zonesManuallyApply").addEventListener("click", applyZonesManualAssignent);
document.getElementById("zonesManuallyCancel").addEventListener("click", cancelZonesManualAssignent);
document.getElementById("zonesAdd").addEventListener("click", addZonesLayer);
document.getElementById("zonesExport").addEventListener("click", downloadZonesData);
document.getElementById("zonesRemove").addEventListener("click", toggleEraseMode);
body.addEventListener("click", function (ev) {
const el = ev.target,
cl = el.classList,
zone = el.parentNode.dataset.id;
if (el.tagName === "FILL-BOX") changeFill(el);
else if (cl.contains("culturePopulation")) changePopulation(zone);
else if (cl.contains("icon-trash-empty")) zoneRemove(zone);
else if (cl.contains("icon-eye")) toggleVisibility(el);
else if (cl.contains("icon-pin")) toggleFog(zone, cl);
if (customization) selectZone(el);
});
body.addEventListener("input", function (ev) {
const el = ev.target;
const zone = zones.select("#" + el.parentNode.dataset.id);
if (el.classList.contains("zoneName")) zone.attr("data-description", el.value);
else if (el.classList.contains("zoneType")) zone.attr("data-type", el.value);
});
// update type filter with a list of used types
function updateFilters() {
const zones = Array.from(document.querySelectorAll("#zones > g"));
const types = unique(zones.map(zone => zone.dataset.type));
const filterSelect = document.getElementById("zonesFilterType");
const typeToFilterBy = types.includes(zonesFilterType.value) ? zonesFilterType.value : "all";
filterSelect.innerHTML =
"<option value='all'>all</option>" + types.map(type => `<option value="${type}">${type}</option>`).join("");
filterSelect.value = typeToFilterBy;
}
// add line for each zone
function zonesEditorAddLines() {
const unit = " " + getAreaUnit();
const typeToFilterBy = document.getElementById("zonesFilterType").value;
const zones = Array.from(document.querySelectorAll("#zones > g"));
const filteredZones = typeToFilterBy === "all" ? zones : zones.filter(zone => zone.dataset.type === typeToFilterBy);
const lines = filteredZones.map(zoneEl => {
const c = zoneEl.dataset.cells ? zoneEl.dataset.cells.split(",").map(c => +c) : [];
const description = zoneEl.dataset.description;
const type = zoneEl.dataset.type;
const fill = zoneEl.getAttribute("fill");
const area = getArea(d3.sum(c.map(i => pack.cells.area[i])));
const rural = d3.sum(c.map(i => pack.cells.pop[i])) * populationRate;
const urban =
d3.sum(c.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization;
const population = rural + urban;
const populationTip = `Total population: ${si(population)}; Rural population: ${si(
rural
)}; Urban population: ${si(urban)}. Click to change`;
const inactive = zoneEl.style.display === "none";
const focused = defs.select("#fog #focus" + zoneEl.id).size();
return `<div class="states" data-id="${zoneEl.id}" data-fill="${fill}" data-description="${description}"
data-type="${type}" data-cells=${c.length} data-area=${area} data-population=${population}>
<fill-box fill="${fill}"></fill-box>
<input data-tip="Zone description. Click and type to change" style="width: 11em" class="zoneName" value="${description}" autocorrect="off" spellcheck="false">
<input data-tip="Zone type. Click and type to change" class="zoneType" value="${type}">
<span data-tip="Cells count" class="icon-check-empty hide"></span>
<div data-tip="Cells count" class="stateCells hide">${c.length}</div>
<span data-tip="Zone area" style="padding-right:4px" class="icon-map-o hide"></span>
<div data-tip="Zone area" class="biomeArea hide">${si(area) + unit}</div>
<span data-tip="${populationTip}" class="icon-male hide"></span>
<div data-tip="${populationTip}" class="culturePopulation hide">${si(population)}</div>
<span data-tip="Drag to raise or lower the zone" class="icon-resize-vertical hide"></span>
<span data-tip="Toggle zone focus" class="icon-pin ${focused ? "" : " inactive"} hide ${
c.length ? "" : " placeholder"
}"></span>
<span data-tip="Toggle zone visibility" class="icon-eye ${inactive ? " inactive" : ""} hide ${
c.length ? "" : " placeholder"
}"></span>
<span data-tip="Remove zone" class="icon-trash-empty hide"></span>
</div>`;
});
body.innerHTML = lines.join("");
// update footer
const totalArea = getArea(graphWidth * graphHeight);
zonesFooterArea.dataset.area = totalArea;
const totalPop =
(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}`;
zonesFooterCells.innerHTML = pack.cells.i.length;
zonesFooterArea.innerHTML = si(totalArea) + unit;
zonesFooterPopulation.innerHTML = si(totalPop);
// add listeners
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => zoneHighlightOn(ev)));
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => zoneHighlightOff(ev)));
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
togglePercentageMode();
}
$("#zonesEditor").dialog({width: "fit-content"});
}
function zoneHighlightOn(event) {
const zone = event.target.dataset.id;
zones.select("#" + zone).style("outline", "1px solid red");
}
function zoneHighlightOff(event) {
const zone = event.target.dataset.id;
zones.select("#" + zone).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";
}
zonesEditorAddLines();
}
$(body).sortable({
items: "div.states",
handle: ".icon-resize-vertical",
containment: "parent",
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 enterZonesManualAssignent() {
if (!layerIsOn("toggleZones")) toggleZones();
customization = 10;
document.querySelectorAll("#zonesBottom > *").forEach(el => (el.style.display = "none"));
document.getElementById("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"));
$("#zonesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}});
tip("Click to select a zone, drag to paint a zone", true);
viewbox
.style("cursor", "crosshair")
.on("click", selectZoneOnMapClick)
.call(d3.drag().on("start", dragZoneBrush))
.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");
}
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);
}
function dragZoneBrush() {
const r = +zonesBrush.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 selection = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
if (!selection) return;
const selected = body.querySelector("div.selected");
const zone = zones.select("#" + selected.dataset.id);
const base = zone.attr("id") + "_"; // id generic part
const dataCells = zone.attr("data-cells");
let cells = dataCells ? dataCells.split(",").map(i => +i) : [];
const erase = document.getElementById("zonesRemove").classList.contains("pressed");
if (erase) {
// remove
selection.forEach(i => {
const index = cells.indexOf(i);
if (index === -1) return;
zone.select("polygon#" + base + i).remove();
cells.splice(index, 1);
});
} else {
// add
selection.forEach(i => {
if (cells.includes(i)) return;
cells.push(i);
zone
.append("polygon")
.attr("points", getPackPolygon(i))
.attr("id", base + i);
});
}
zone.attr("data-cells", cells);
});
}
function moveZoneBrush() {
showMainTip();
const point = d3.mouse(this);
const radius = +zonesBrush.value;
moveCircle(point[0], point[1], 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";
});
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);
});
exitZonesManualAssignment();
}
function exitZonesManualAssignment(close) {
customization = 0;
removeCircle();
document.querySelectorAll("#zonesBottom > *").forEach(el => (el.style.display = "inline-block"));
document.getElementById("zonesManuallyButtons").style.display = "none";
zonesEditor.querySelectorAll(".hide:not(.show)").forEach(el => el.classList.remove("hidden"));
zonesFooter.style.display = "block";
body.querySelectorAll("div > input, select, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close)
$("#zonesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}});
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");
const callback = newFill => {
el.fill = newFill;
document.getElementById(el.parentNode.dataset.id).setAttribute("fill", newFill);
};
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 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);
cl.toggle("inactive");
}
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]);
});
drawLegend("Zones", data);
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = +zonesFooterCells.innerHTML;
const totalArea = +zonesFooterArea.dataset.area;
const totalPopulation = +zonesFooterPopulation.dataset.population;
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) + "%";
});
} else {
body.dataset.type = "absolute";
zonesEditorAddLines();
}
}
function addZonesLayer() {
const id = getNextId("zone");
const description = "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);
zonesEditorAddLines();
}
function downloadZonesData() {
const unit = areaUnit.value === "square" ? distanceUnitInput.value + "2" : areaUnit.value;
let data = "Id,Fill,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.description + ",";
data += el.dataset.type + ",";
data += el.dataset.cells + ",";
data += el.dataset.area + ",";
data += el.dataset.population + "\n";
});
const name = getFileName("Zones") + ".csv";
downloadFile(data, name);
}
function toggleEraseMode() {
this.classList.toggle("pressed");
}
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 rural = rn(d3.sum(cells.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
);
const total = rural + urban;
const l = n => Number(n).toLocaleString();
alertMessage.innerHTML = /* html */ `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"
} />
<p>Total population: ${l(total)} <span id="totalPop">${l(
total
)}</span> (<span id="totalPopPerc">100</span>%)</p>`;
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 zone population",
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) {
cells.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 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));
}
zonesEditorAddLines();
}
}
function zoneRemove(zone) {
zones.select("#" + zone).remove();
unfog("focusZone" + zone);
zonesEditorAddLines();
}
}

135
src/modules/voronoi.js Normal file
View file

@ -0,0 +1,135 @@
class Voronoi {
/**
* Creates a Voronoi diagram from the given Delaunator, a list of points, and the number of points. The Voronoi diagram is constructed using (I think) the {@link https://en.wikipedia.org/wiki/Bowyer%E2%80%93Watson_algorithm |Bowyer-Watson Algorithm}
* The {@link https://github.com/mapbox/delaunator/ |Delaunator} library uses {@link https://en.wikipedia.org/wiki/Doubly_connected_edge_list |half-edges} to represent the relationship between points and triangles.
* @param {{triangles: Uint32Array, halfedges: Int32Array}} delaunay A {@link https://github.com/mapbox/delaunator/blob/master/index.js |Delaunator} instance.
* @param {[number, number][]} points A list of coordinates.
* @param {number} pointsN The number of points.
*/
constructor(delaunay, points, pointsN) {
this.delaunay = delaunay;
this.points = points;
this.pointsN = pointsN;
this.cells = { v: [], c: [], b: [] }; // voronoi cells: v = cell vertices, c = adjacent cells, b = near-border cell
this.vertices = { p: [], v: [], c: [] }; // cells vertices: p = vertex coordinates, v = neighboring vertices, c = adjacent cells
// Half-edges are the indices into the delaunator outputs:
// delaunay.triangles[e] gives the point ID where the half-edge starts
// delaunay.halfedges[e] returns either the opposite half-edge in the adjacent triangle, or -1 if there's not an adjacent triangle.
for (let e = 0; e < this.delaunay.triangles.length; e++) {
const p = this.delaunay.triangles[this.nextHalfedge(e)];
if (p < this.pointsN && !this.cells.c[p]) {
const edges = this.edgesAroundPoint(e);
this.cells.v[p] = edges.map(e => this.triangleOfEdge(e)); // cell: adjacent vertex
this.cells.c[p] = edges.map(e => this.delaunay.triangles[e]).filter(c => c < this.pointsN); // cell: adjacent valid cells
this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border
}
const t = this.triangleOfEdge(e);
if (!this.vertices.p[t]) {
this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates
this.vertices.v[t] = this.trianglesAdjacentToTriangle(t); // vertex: adjacent vertices
this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells
}
}
}
/**
* Gets the IDs of the points comprising the given triangle. Taken from {@link https://mapbox.github.io/delaunator/#triangle-to-points| the Delaunator docs.}
* @param {number} t The index of the triangle
* @returns {[number, number, number]} The IDs of the points comprising the given triangle.
*/
pointsOfTriangle(t) {
return this.edgesOfTriangle(t).map(edge => this.delaunay.triangles[edge]);
}
/**
* Identifies what triangles are adjacent to the given triangle. Taken from {@link https://mapbox.github.io/delaunator/#triangle-to-triangles| the Delaunator docs.}
* @param {number} t The index of the triangle
* @returns {number[]} The indices of the triangles that share half-edges with this triangle.
*/
trianglesAdjacentToTriangle(t) {
let triangles = [];
for (let edge of this.edgesOfTriangle(t)) {
let opposite = this.delaunay.halfedges[edge];
triangles.push(this.triangleOfEdge(opposite));
}
return triangles;
}
/**
* Gets the indices of all the incoming and outgoing half-edges that touch the given point. Taken from {@link https://mapbox.github.io/delaunator/#point-to-edges| the Delaunator docs.}
* @param {number} start The index of an incoming half-edge that leads to the desired point
* @returns {number[]} The indices of all half-edges (incoming or outgoing) that touch the point.
*/
edgesAroundPoint(start) {
const result = [];
let incoming = start;
do {
result.push(incoming);
const outgoing = this.nextHalfedge(incoming);
incoming = this.delaunay.halfedges[outgoing];
} while (incoming !== -1 && incoming !== start && result.length < 20);
return result;
}
/**
* Returns the center of the triangle located at the given index.
* @param {number} t The index of the triangle
* @returns {[number, number]}
*/
triangleCenter(t) {
let vertices = this.pointsOfTriangle(t).map(p => this.points[p]);
return this.circumcenter(vertices[0], vertices[1], vertices[2]);
}
/**
* Retrieves all of the half-edges for a specific triangle `t`. Taken from {@link https://mapbox.github.io/delaunator/#edge-and-triangle| the Delaunator docs.}
* @param {number} t The index of the triangle
* @returns {[number, number, number]} The edges of the triangle.
*/
edgesOfTriangle(t) { return [3 * t, 3 * t + 1, 3 * t + 2]; }
/**
* Enables lookup of a triangle, given one of the half-edges of that triangle. Taken from {@link https://mapbox.github.io/delaunator/#edge-and-triangle| the Delaunator docs.}
* @param {number} e The index of the edge
* @returns {number} The index of the triangle
*/
triangleOfEdge(e) { return Math.floor(e / 3); }
/**
* Moves to the next half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.}
* @param {number} e The index of the current half edge
* @returns {number} The index of the next half edge
*/
nextHalfedge(e) { return (e % 3 === 2) ? e - 2 : e + 1; }
/**
* Moves to the previous half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.}
* @param {number} e The index of the current half edge
* @returns {number} The index of the previous half edge
*/
prevHalfedge(e) { return (e % 3 === 0) ? e + 2 : e - 1; }
/**
* Finds the circumcenter of the triangle identified by points a, b, and c. Taken from {@link https://en.wikipedia.org/wiki/Circumscribed_circle#Circumcenter_coordinates| Wikipedia}
* @param {[number, number]} a The coordinates of the first point of the triangle
* @param {[number, number]} b The coordinates of the second point of the triangle
* @param {[number, number]} c The coordinates of the third point of the triangle
* @return {[number, number]} The coordinates of the circumcenter of the triangle.
*/
circumcenter(a, b, c) {
const [ax, ay] = a;
const [bx, by] = b;
const [cx, cy] = c;
const ad = ax * ax + ay * ay;
const bd = bx * bx + by * by;
const cd = cx * cx + cy * cy;
const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
return [
Math.floor(1 / D * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))),
Math.floor(1 / D * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax)))
];
}
}

56
src/modules/zoom.js Normal file
View file

@ -0,0 +1,56 @@
import {debounce} from "/src/utils/functionUtils";
// temporary expose to global
window.scale = 1;
window.viewX = 0;
window.viewY = 0;
window.Zoom = (function () {
function onZoom() {
const {k, x, y} = d3.event.transform;
const isScaleChanged = Boolean(scale - k);
const isPositionChanged = Boolean(viewX - x || viewY - y);
if (!isScaleChanged && !isPositionChanged) return;
scale = k;
viewX = x;
viewY = y;
handleZoom(isScaleChanged, isPositionChanged);
}
const onZoomDebouced = debounce(onZoom, 50);
const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoomDebouced);
function setZoomBehavior() {
svg.call(zoom);
}
// zoom to a specific point
function to(x, y, z = 8, d = 2000) {
const transform = d3.zoomIdentity.translate(x * -z + graphWidth / 2, y * -z + graphHeight / 2).scale(z);
svg.transition().duration(d).call(zoom.transform, transform);
}
// reset zoom to initial
function reset(d = 1000) {
svg.transition().duration(d).call(zoom.transform, d3.zoomIdentity);
}
function scaleExtent([min, max]) {
zoom.scaleExtent([min, max]);
}
function translateExtent([x1, y1, x2, y2]) {
zoom.translateExtent([
[x1, y1],
[x2, y2]
]);
}
function scaleTo(element, scale) {
zoom.scaleTo(element, scale);
}
return {setZoomBehavior, to, reset, scaleExtent, translateExtent, scaleTo};
})();