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