diff --git a/index.html b/index.html
index 18d938a6..b92ba7bf 100644
--- a/index.html
+++ b/index.html
@@ -4315,12 +4315,22 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/utils.js b/modules/utils.js
deleted file mode 100644
index 1aa308ca..00000000
--- a/modules/utils.js
+++ /dev/null
@@ -1,832 +0,0 @@
-// FMG helper functions
-"use strict";
-
-// add boundary points to pseudo-clip voronoi cells
-function getBoundaryPoints(width, height, spacing) {
- const offset = rn(-1 * spacing);
- const bSpacing = spacing * 2;
- const w = width - offset * 2;
- const h = height - offset * 2;
- const numberX = Math.ceil(w / bSpacing) - 1;
- const numberY = Math.ceil(h / bSpacing) - 1;
- let points = [];
- for (let i = 0.5; i < numberX; i++) {
- let x = Math.ceil((w * i) / numberX + offset);
- points.push([x, offset], [x, h + offset]);
- }
- for (let i = 0.5; i < numberY; i++) {
- let y = Math.ceil((h * i) / numberY + offset);
- points.push([offset, y], [w + offset, y]);
- }
- return points;
-}
-
-// get points on a regular square grid and jitter them a bit
-function getJitteredGrid(width, height, spacing) {
- const radius = spacing / 2; // square radius
- const jittering = radius * 0.9; // max deviation
- const doubleJittering = jittering * 2;
- const jitter = () => Math.random() * doubleJittering - jittering;
-
- let points = [];
- for (let y = radius; y < height; y += spacing) {
- for (let x = radius; x < width; x += spacing) {
- const xj = Math.min(rn(x + jitter(), 2), width);
- const yj = Math.min(rn(y + jitter(), 2), height);
- points.push([xj, yj]);
- }
- }
- return points;
-}
-
-// return cell index on a regular square grid
-function findGridCell(x, y) {
- return Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1));
-}
-
-// return array of cell indexes in radius on a regular square grid
-function findGridAll(x, y, radius) {
- const c = grid.cells.c;
- let r = Math.floor(radius / grid.spacing);
- let found = [findGridCell(x, y)];
- if (!r || radius === 1) return found;
- if (r > 0) found = found.concat(c[found[0]]);
- if (r > 1) {
- let frontier = c[found[0]];
- while (r > 1) {
- let cycle = frontier.slice();
- frontier = [];
- cycle.forEach(function (s) {
- c[s].forEach(function (e) {
- if (found.indexOf(e) !== -1) return;
- found.push(e);
- frontier.push(e);
- });
- });
- r--;
- }
- }
-
- return found;
-}
-
-// return closest pack points quadtree datum
-function find(x, y, radius = Infinity) {
- return pack.cells.q.find(x, y, radius);
-}
-
-// return closest cell index
-function findCell(x, y, radius = Infinity) {
- const found = pack.cells.q.find(x, y, radius);
- return found ? found[2] : undefined;
-}
-
-// return array of cell indexes in radius
-function findAll(x, y, radius) {
- const found = pack.cells.q.findAll(x, y, radius);
- return found.map(r => r[2]);
-}
-
-// get polygon points for packed cells knowing cell id
-function getPackPolygon(i) {
- return pack.cells.v[i].map(v => pack.vertices.p[v]);
-}
-
-// get polygon points for initial cells knowing cell id
-function getGridPolygon(i) {
- return grid.cells.v[i].map(v => grid.vertices.p[v]);
-}
-
-// 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;
- }
-}
-
-// filter land cells
-function isLand(i) {
- return pack.cells.h[i] >= 20;
-}
-
-// filter water cells
-function isWater(i) {
- return pack.cells.h[i] < 20;
-}
-
-// convert RGB color string to HEX without #
-function toHEX(rgb) {
- if (rgb.charAt(0) === "#") {
- return rgb;
- }
- rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
- return rgb && rgb.length === 4
- ? "#" +
- ("0" + parseInt(rgb[1], 10).toString(16)).slice(-2) +
- ("0" + parseInt(rgb[2], 10).toString(16)).slice(-2) +
- ("0" + parseInt(rgb[3], 10).toString(16)).slice(-2)
- : "";
-}
-
-// return array of standard shuffled colors
-function getColors(number) {
- const c12 = ["#dababf", "#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5", "#c6b9c1", "#bc80bd", "#ccebc5", "#ffed6f", "#8dd3c7", "#eb8de7"];
- const cRB = d3.scaleSequential(d3.interpolateRainbow);
- const colors = d3.shuffle(d3.range(number).map(i => (i < 12 ? c12[i] : d3.color(cRB((i - 12) / (number - 12))).hex())));
- return colors;
-}
-
-function getRandomColor() {
- return d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
-}
-
-// mix a color with a random color
-function getMixedColor(color, mix = 0.2, bright = 0.3) {
- const c = color && color[0] === "#" ? color : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one
- return d3.color(d3.interpolate(c, getRandomColor())(mix)).brighter(bright).hex();
-}
-
-// conver temperature from °C to other scales
-function convertTemperature(c) {
- switch (temperatureScale.value) {
- case "°C":
- return c + "°C";
- case "°F":
- return rn((c * 9) / 5 + 32) + "°F";
- case "K":
- return rn(c + 273.15) + "K";
- case "°R":
- return rn(((c + 273.15) * 9) / 5) + "°R";
- case "°De":
- return rn(((100 - c) * 3) / 2) + "°De";
- case "°N":
- return rn((c * 33) / 100) + "°N";
- case "°Ré":
- return rn((c * 4) / 5) + "°Ré";
- case "°Rø":
- return rn((c * 21) / 40 + 7.5) + "°Rø";
- default:
- return c + "°C";
- }
-}
-
-// random number in a range
-function rand(min, max) {
- if (min === undefined && max === undefined) return Math.random();
- if (max === undefined) {
- max = min;
- min = 0;
- }
- return Math.floor(Math.random() * (max - min + 1)) + min;
-}
-
-// probability shorthand
-function P(probability) {
- if (probability >= 1) return true;
- if (probability <= 0) return false;
- return Math.random() < probability;
-}
-
-function each(n) {
- return i => i % n === 0;
-}
-
-// random number (normal or gaussian distribution)
-function gauss(expected = 100, deviation = 30, min = 0, max = 300, round = 0) {
- return rn(minmax(d3.randomNormal(expected, deviation)(), min, max), round);
-}
-
-// probability shorthand for floats
-function Pint(float) {
- return ~~float + +P(float % 1);
-}
-
-// round value to d decimals
-function rn(v, d = 0) {
- const m = Math.pow(10, d);
- return Math.round(v * m) / m;
-}
-
-// round string to d decimals
-function round(s, d = 1) {
- return s.replace(/[\d\.-][\d\.e-]*/g, function (n) {
- return rn(n, d);
- });
-}
-
-// corvent number to short string with SI postfix
-function si(n) {
- if (n >= 1e9) return rn(n / 1e9, 1) + "B";
- if (n >= 1e8) return rn(n / 1e6) + "M";
- if (n >= 1e6) return rn(n / 1e6, 1) + "M";
- if (n >= 1e4) return rn(n / 1e3) + "K";
- if (n >= 1e3) return rn(n / 1e3, 1) + "K";
- return rn(n);
-}
-
-// getInteger number from user input data
-function getInteger(value) {
- const metric = value.slice(-1);
- if (metric === "K") return parseInt(value.slice(0, -1) * 1e3);
- if (metric === "M") return parseInt(value.slice(0, -1) * 1e6);
- if (metric === "B") return parseInt(value.slice(0, -1) * 1e9);
- return parseInt(value);
-}
-
-// remove parent element (usually if child is clicked)
-function removeParent() {
- this.parentNode.parentNode.removeChild(this.parentNode);
-}
-
-// return string with 1st char capitalized
-function capitalize(string) {
- return string.charAt(0).toUpperCase() + string.slice(1);
-}
-
-// transform string to array [translateX,translateY,rotateDeg,rotateX,rotateY,scale]
-function parseTransform(string) {
- if (!string) {
- return [0, 0, 0, 0, 0, 1];
- }
- const a = string
- .replace(/[a-z()]/g, "")
- .replace(/[ ]/g, ",")
- .split(",");
- return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1];
-}
-
-// findAll d3.quandtree search from https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
-void (function addFindAll() {
- const Quad = function (node, x0, y0, x1, y1) {
- this.node = node;
- this.x0 = x0;
- this.y0 = y0;
- this.x1 = x1;
- this.y1 = y1;
- };
-
- const tree_filter = function (x, y, radius) {
- var t = {x, y, x0: this._x0, y0: this._y0, x3: this._x1, y3: this._y1, quads: [], node: this._root};
- if (t.node) {
- t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
- }
- radiusSearchInit(t, radius);
-
- var i = 0;
- while ((t.q = t.quads.pop())) {
- i++;
-
- // Stop searching if this quadrant can’t contain a closer node.
- if (!(t.node = t.q.node) || (t.x1 = t.q.x0) > t.x3 || (t.y1 = t.q.y0) > t.y3 || (t.x2 = t.q.x1) < t.x0 || (t.y2 = t.q.y1) < t.y0) continue;
-
- // Bisect the current quadrant.
- if (t.node.length) {
- t.node.explored = true;
- var xm = (t.x1 + t.x2) / 2,
- ym = (t.y1 + t.y2) / 2;
-
- t.quads.push(
- new Quad(t.node[3], xm, ym, t.x2, t.y2),
- new Quad(t.node[2], t.x1, ym, xm, t.y2),
- new Quad(t.node[1], xm, t.y1, t.x2, ym),
- new Quad(t.node[0], t.x1, t.y1, xm, ym)
- );
-
- // Visit the closest quadrant first.
- if ((t.i = ((y >= ym) << 1) | (x >= xm))) {
- t.q = t.quads[t.quads.length - 1];
- t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
- t.quads[t.quads.length - 1 - t.i] = t.q;
- }
- }
-
- // Visit this point. (Visiting coincident points isn’t necessary!)
- else {
- var dx = x - +this._x.call(null, t.node.data),
- dy = y - +this._y.call(null, t.node.data),
- d2 = dx * dx + dy * dy;
- radiusSearchVisit(t, d2);
- }
- }
- return t.result;
- };
- d3.quadtree.prototype.findAll = tree_filter;
-
- var radiusSearchInit = function (t, radius) {
- t.result = [];
- (t.x0 = t.x - radius), (t.y0 = t.y - radius);
- (t.x3 = t.x + radius), (t.y3 = t.y + radius);
- t.radius = radius * radius;
- };
-
- var radiusSearchVisit = function (t, d2) {
- t.node.data.scanned = true;
- if (d2 < t.radius) {
- do {
- t.result.push(t.node.data);
- t.node.data.selected = true;
- } while ((t.node = t.node.next));
- }
- };
-})();
-
-// get segment of any point on polyline
-function getSegmentId(points, point, step = 10) {
- if (points.length === 2) return 1;
- const d2 = (p1, p2) => (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2;
-
- let minSegment = 1;
- let minDist = Infinity;
-
- for (let i = 0; i < points.length - 1; i++) {
- const p1 = points[i];
- const p2 = points[i + 1];
-
- const length = Math.sqrt(d2(p1, p2));
- const segments = Math.ceil(length / step);
- const dx = (p2[0] - p1[0]) / segments;
- const dy = (p2[1] - p1[1]) / segments;
-
- for (let s = 0; s < segments; s++) {
- const x = p1[0] + s * dx;
- const y = p1[1] + s * dy;
- const dist2 = d2(point, [x, y]);
-
- if (dist2 >= minDist) continue;
- minDist = dist2;
- minSegment = i + 1;
- }
- }
-
- return minSegment;
-}
-
-function minmax(value, min, max) {
- return Math.min(Math.max(value, min), max);
-}
-
-// normalization function
-function normalize(val, min, max) {
- return minmax((val - min) / (max - min), 0, 1);
-}
-
-// return a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min)
-// from https://gamedev.stackexchange.com/a/116875
-function biased(min, max, ex) {
- return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
-}
-
-// return array of values common for both array a and array b
-function common(a, b) {
- const setB = new Set(b);
- return [...new Set(a)].filter(a => setB.has(a));
-}
-
-// clip polygon by graph bbox
-function clipPoly(points, secure = 0) {
- return polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
-}
-
-// check if char is vowel or can serve as vowel
-function vowel(c) {
- return `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`.includes(c);
-}
-
-// remove vowels from the end of the string
-function trimVowels(string) {
- while (string.length > 3 && vowel(last(string))) {
- string = string.slice(0, -1);
- }
- return string;
-}
-
-// get adjective form from noun
-function getAdjective(string) {
- // special cases for some suffixes
- if (string.length > 8 && string.slice(-6) === "orszag") return string.slice(0, -6);
- if (string.length > 6 && string.slice(-4) === "stan") return string.slice(0, -4);
- if (P(0.5) && string.slice(-4) === "land") return string + "ic";
- if (string.slice(-4) === " Guo") string = string.slice(0, -4);
-
- // don't change is name ends on suffix
- if (string.slice(-2) === "an") return string;
- if (string.slice(-3) === "ese") return string;
- if (string.slice(-1) === "i") return string;
-
- const end = string.slice(-1); // last letter of string
- if (end === "a") return (string += "n");
- if (end === "o") return (string = trimVowels(string) + "an");
- if (vowel(end) || end === "c") return (string += "an"); // ceiuy
- if (end === "m" || end === "n") return (string += "ese");
- if (end === "q") return (string += "i");
- return trimVowels(string) + "ian";
-}
-
-// get ordinal out of integer: 1 => 1st
-const nth = n => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th");
-
-// get two-letters code (abbreviation) from string
-function abbreviate(name, restricted = []) {
- const parsed = name.replace("Old ", "O ").replace(/[()]/g, ""); // remove Old prefix and parentheses
- const words = parsed.split(" ");
- const letters = words.join("");
-
- let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2);
- for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) {
- code = letters[0] + letters[i].toUpperCase();
- }
- return code;
-}
-
-// conjunct array: [A,B,C] => "A, B and C"
-function list(array) {
- if (!Intl.ListFormat) return array.join(", ");
- const conjunction = new Intl.ListFormat(window.lang || "en", {style: "long", type: "conjunction"});
- return conjunction.format(array);
-}
-
-// split string into 2 almost equal parts not breaking words
-function splitInTwo(str) {
- const half = str.length / 2;
- const ar = str.split(" ");
- if (ar.length < 2) return ar; // only one word
- let first = "",
- last = "",
- middle = "",
- rest = "";
-
- ar.forEach((w, d) => {
- if (d + 1 !== ar.length) w += " ";
- rest += w;
- if (!first || rest.length < half) first += w;
- else if (!middle) middle = w;
- else last += w;
- });
-
- if (!last) return [first, middle];
- if (first.length < last.length) return [first + middle, last];
- return [first, middle + last];
-}
-
-// return the last element of array
-function last(array) {
- return array[array.length - 1];
-}
-
-// return random value from the array
-function ra(array) {
- return array[Math.floor(Math.random() * array.length)];
-}
-
-// return random value from weighted array {"key1":weight1, "key2":weight2}
-function rw(object) {
- const array = [];
- for (const key in object) {
- for (let i = 0; i < object[key]; i++) {
- array.push(key);
- }
- }
- return array[Math.floor(Math.random() * array.length)];
-}
-
-// return value in range [0, 100] (height range)
-function lim(v) {
- return minmax(v, 0, 100);
-}
-
-// get number from string in format "1-3" or "2" or "0.5"
-function getNumberInRange(r) {
- if (typeof r !== "string") {
- ERROR && console.error("The value should be a string", r);
- return 0;
- }
- if (!isNaN(+r)) return ~~r + +P(r - ~~r);
- const sign = r[0] === "-" ? -1 : 1;
- if (isNaN(+r[0])) r = r.slice(1);
- const range = r.includes("-") ? r.split("-") : null;
- if (!range) {
- ERROR && console.error("Cannot parse the number. Check the format", r);
- return 0;
- }
- const count = rand(range[0] * sign, +range[1]);
- if (isNaN(count) || count < 0) {
- ERROR && console.error("Cannot parse number. Check the format", r);
- return 0;
- }
- return count;
-}
-
-// return center point of common edge of 2 pack cells
-function getMiddlePoint(cell1, cell2) {
- const {cells, vertices} = pack;
-
- const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
- const [x1, y1] = vertices.p[commonVertices[0]];
- const [x2, y2] = vertices.p[commonVertices[1]];
-
- const x = (x1 + x2) / 2;
- const y = (y1 + y2) / 2;
-
- return [x, y];
-}
-
-// helper function non-used for the generation
-function drawCellsValue(data) {
- debug.selectAll("text").remove();
- debug
- .selectAll("text")
- .data(data)
- .enter()
- .append("text")
- .attr("x", (d, i) => pack.cells.p[i][0])
- .attr("y", (d, i) => pack.cells.p[i][1])
- .text(d => d);
-}
-
-// helper function non-used for the generation
-function drawPolygons(data) {
- const max = d3.max(data),
- min = d3.min(data),
- scheme = getColorScheme();
- data = data.map(d => 1 - normalize(d, min, max));
-
- debug.selectAll("polygon").remove();
- debug
- .selectAll("polygon")
- .data(data)
- .enter()
- .append("polygon")
- .attr("points", (d, i) => getPackPolygon(i))
- .attr("fill", d => scheme(d))
- .attr("stroke", d => scheme(d));
-}
-
-// polyfill for composedPath
-function getComposedPath(node) {
- let parent;
- if (node.parentNode) parent = node.parentNode;
- else if (node.host) parent = node.host;
- else if (node.defaultView) parent = node.defaultView;
- if (parent !== undefined) return [node].concat(getComposedPath(parent));
- return [node];
-}
-
-// polyfill for replaceAll
-if (!String.prototype.replaceAll) {
- String.prototype.replaceAll = function (str, newStr) {
- if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str, newStr);
- return this.replace(new RegExp(str, "g"), newStr);
- };
-}
-
-// get next unused id
-function getNextId(core, i = 1) {
- while (document.getElementById(core + i)) i++;
- return core + i;
-}
-
-function debounce(func, ms) {
- let isCooldown = false;
-
- return function () {
- if (isCooldown) return;
- func.apply(this, arguments);
- isCooldown = true;
- setTimeout(() => (isCooldown = false), ms);
- };
-}
-
-function throttle(func, ms) {
- let isThrottled = false;
- let savedArgs;
- let savedThis;
-
- function wrapper() {
- if (isThrottled) {
- savedArgs = arguments;
- savedThis = this;
- return;
- }
-
- func.apply(this, arguments);
- isThrottled = true;
-
- setTimeout(function () {
- isThrottled = false;
- if (savedArgs) {
- wrapper.apply(savedThis, savedArgs);
- savedArgs = savedThis = null;
- }
- }, ms);
- }
-
- return wrapper;
-}
-
-// parse error to get the readable string in Chrome and Firefox
-function parseError(error) {
- const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
- const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack;
- const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi;
- const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + "");
- const errorParsed = errorNoURL.replace(/at /gi, "
at ");
- return errorParsed;
-}
-
-// polyfills
-if (Array.prototype.flat === undefined) {
- Array.prototype.flat = function () {
- return this.reduce((acc, val) => (Array.isArray(val) ? acc.concat(val.flat()) : acc.concat(val)), []);
- };
-}
-
-// check if string is a valid for JSON parse
-JSON.isValid = str => {
- try {
- JSON.parse(str);
- } catch (e) {
- return false;
- }
- return true;
-};
-
-function getBase64(url, callback) {
- const xhr = new XMLHttpRequest();
- xhr.onload = function () {
- const reader = new FileReader();
- reader.onloadend = function () {
- callback(reader.result);
- };
- reader.readAsDataURL(xhr.response);
- };
- xhr.open("GET", url);
- xhr.responseType = "blob";
- xhr.send();
-}
-
-function getAbsolutePath(href) {
- if (!href) return "";
- const link = document.createElement("a");
- link.href = href;
- return link.href;
-}
-
-// open URL in a new tab or window
-function openURL(url) {
- window.open(url, "_blank");
-}
-
-// open project wiki-page
-function wiki(page) {
- window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank");
-}
-
-// wrap URL into html a element
-function link(URL, description) {
- return `${description}`;
-}
-
-function isCtrlClick(event) {
- // meta key is cmd key on MacOs
- return event.ctrlKey || event.metaKey;
-}
-
-function generateDate(from = 100, to = 1000) {
- return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {year: "numeric", month: "long", day: "numeric"});
-}
-
-function getQGIScoordinates(x, y) {
- const cx = mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT;
- const cy = mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT; // this is inverted in QGIS otherwise
- return [cx, cy];
-}
-
-// prompt replacer (prompt does not work in Electron)
-void (function () {
- const prompt = document.getElementById("prompt");
- const form = prompt.querySelector("#promptForm");
-
- window.prompt = function (promptText = "Please provide an input", options = {default: 1, step: 0.01, min: 0, max: 100}, callback) {
- if (options.default === undefined) {
- ERROR && console.error("Prompt: options object does not have default value defined");
- return;
- }
- const input = prompt.querySelector("#promptInput");
- prompt.querySelector("#promptText").innerHTML = promptText;
- const type = typeof options.default === "number" ? "number" : "text";
- input.type = type;
- if (options.step !== undefined) input.step = options.step;
- if (options.min !== undefined) input.min = options.min;
- if (options.max !== undefined) input.max = options.max;
- input.placeholder = "type a " + type;
- input.value = options.default;
- prompt.style.display = "block";
-
- form.addEventListener(
- "submit",
- event => {
- prompt.style.display = "none";
- const v = type === "number" ? +input.value : input.value;
- event.preventDefault();
- if (callback) callback(v);
- },
- {once: true}
- );
- };
-
- const cancel = prompt.querySelector("#promptCancel");
- cancel.addEventListener("click", () => (prompt.style.display = "none"));
-})();
-
-// indexedDB; ldb object
-!(function () {
- function e(t, o) {
- return n
- ? void (n.transaction("s").objectStore("s").get(t).onsuccess = function (e) {
- var t = (e.target.result && e.target.result.v) || null;
- o(t);
- })
- : void setTimeout(function () {
- e(t, o);
- }, 100);
- }
- var t = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
- if (!t) return void ERROR && console.error("indexedDB not supported");
- var n,
- o = {k: "", v: ""},
- r = t.open("d2", 1);
- (r.onsuccess = function (e) {
- n = this.result;
- }),
- (r.onerror = function (e) {
- ERROR && console.error("indexedDB request error"), INFO && console.log(e);
- }),
- (r.onupgradeneeded = function (e) {
- n = null;
- var t = e.target.result.createObjectStore("s", {keyPath: "k"});
- t.transaction.oncomplete = function (e) {
- n = e.target.db;
- };
- }),
- (window.ldb = {
- get: e,
- set: function (e, t) {
- (o.k = e), (o.v = t), n.transaction("s", "readwrite").objectStore("s").put(o);
- }
- });
-})();
diff --git a/utils/arrayUtils.js b/utils/arrayUtils.js
new file mode 100644
index 00000000..c9a0d074
--- /dev/null
+++ b/utils/arrayUtils.js
@@ -0,0 +1,13 @@
+"use strict";
+// FMG utils related to arrays
+
+// return the last element of array
+function last(array) {
+ return array[array.length - 1];
+}
+
+// return array of values common for both array a and array b
+function common(a, b) {
+ const setB = new Set(b);
+ return [...new Set(a)].filter(a => setB.has(a));
+}
diff --git a/utils/colorUtils.js b/utils/colorUtils.js
new file mode 100644
index 00000000..3a5c6d24
--- /dev/null
+++ b/utils/colorUtils.js
@@ -0,0 +1,33 @@
+"use strict";
+// FMG utils related to colors
+
+// convert RGB color string to HEX without #
+function toHEX(rgb) {
+ if (rgb.charAt(0) === "#") return rgb;
+
+ rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
+ return rgb && rgb.length === 4
+ ? "#" +
+ ("0" + parseInt(rgb[1], 10).toString(16)).slice(-2) +
+ ("0" + parseInt(rgb[2], 10).toString(16)).slice(-2) +
+ ("0" + parseInt(rgb[3], 10).toString(16)).slice(-2)
+ : "";
+}
+
+// return array of standard shuffled colors
+function getColors(number) {
+ const c12 = ["#dababf", "#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5", "#c6b9c1", "#bc80bd", "#ccebc5", "#ffed6f", "#8dd3c7", "#eb8de7"];
+ const cRB = d3.scaleSequential(d3.interpolateRainbow);
+ const colors = d3.shuffle(d3.range(number).map(i => (i < 12 ? c12[i] : d3.color(cRB((i - 12) / (number - 12))).hex())));
+ return colors;
+}
+
+function getRandomColor() {
+ return d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
+}
+
+// mix a color with a random color
+function getMixedColor(color, mix = 0.2, bright = 0.3) {
+ const c = color && color[0] === "#" ? color : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one
+ return d3.color(d3.interpolate(c, getRandomColor())(mix)).brighter(bright).hex();
+}
diff --git a/utils/commonUtils.js b/utils/commonUtils.js
new file mode 100644
index 00000000..a88c75f6
--- /dev/null
+++ b/utils/commonUtils.js
@@ -0,0 +1,219 @@
+"use strict";
+// FMG helper functions
+
+// clip polygon by graph bbox
+function clipPoly(points, secure = 0) {
+ return polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
+}
+
+// get segment of any point on polyline
+function getSegmentId(points, point, step = 10) {
+ if (points.length === 2) return 1;
+ const d2 = (p1, p2) => (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2;
+
+ let minSegment = 1;
+ let minDist = Infinity;
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+
+ const length = Math.sqrt(d2(p1, p2));
+ const segments = Math.ceil(length / step);
+ const dx = (p2[0] - p1[0]) / segments;
+ const dy = (p2[1] - p1[1]) / segments;
+
+ for (let s = 0; s < segments; s++) {
+ const x = p1[0] + s * dx;
+ const y = p1[1] + s * dy;
+ const dist2 = d2(point, [x, y]);
+
+ if (dist2 >= minDist) continue;
+ minDist = dist2;
+ minSegment = i + 1;
+ }
+ }
+
+ return minSegment;
+}
+
+// return center point of common edge of 2 pack cells
+function getMiddlePoint(cell1, cell2) {
+ const {cells, vertices} = pack;
+
+ const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
+ const [x1, y1] = vertices.p[commonVertices[0]];
+ const [x2, y2] = vertices.p[commonVertices[1]];
+
+ const x = (x1 + x2) / 2;
+ const y = (y1 + y2) / 2;
+
+ return [x, y];
+}
+
+function debounce(func, ms) {
+ let isCooldown = false;
+
+ return function () {
+ if (isCooldown) return;
+ func.apply(this, arguments);
+ isCooldown = true;
+ setTimeout(() => (isCooldown = false), ms);
+ };
+}
+
+function throttle(func, ms) {
+ let isThrottled = false;
+ let savedArgs;
+ let savedThis;
+
+ function wrapper() {
+ if (isThrottled) {
+ savedArgs = arguments;
+ savedThis = this;
+ return;
+ }
+
+ func.apply(this, arguments);
+ isThrottled = true;
+
+ setTimeout(function () {
+ isThrottled = false;
+ if (savedArgs) {
+ wrapper.apply(savedThis, savedArgs);
+ savedArgs = savedThis = null;
+ }
+ }, ms);
+ }
+
+ return wrapper;
+}
+
+// parse error to get the readable string in Chrome and Firefox
+function parseError(error) {
+ const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
+ const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack;
+ const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi;
+ const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + "");
+ const errorParsed = errorNoURL.replace(/at /gi, "
at ");
+ return errorParsed;
+}
+
+function getBase64(url, callback) {
+ const xhr = new XMLHttpRequest();
+ xhr.onload = function () {
+ const reader = new FileReader();
+ reader.onloadend = function () {
+ callback(reader.result);
+ };
+ reader.readAsDataURL(xhr.response);
+ };
+ xhr.open("GET", url);
+ xhr.responseType = "blob";
+ xhr.send();
+}
+
+// open URL in a new tab or window
+function openURL(url) {
+ window.open(url, "_blank");
+}
+
+// open project wiki-page
+function wiki(page) {
+ window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank");
+}
+
+// wrap URL into html a element
+function link(URL, description) {
+ return `${description}`;
+}
+
+function isCtrlClick(event) {
+ // meta key is cmd key on MacOs
+ return event.ctrlKey || event.metaKey;
+}
+
+function generateDate(from = 100, to = 1000) {
+ return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {year: "numeric", month: "long", day: "numeric"});
+}
+
+function getQGIScoordinates(x, y) {
+ const cx = mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT;
+ const cy = mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT; // this is inverted in QGIS otherwise
+ return [cx, cy];
+}
+
+// prompt replacer (prompt does not work in Electron)
+void (function () {
+ const prompt = document.getElementById("prompt");
+ const form = prompt.querySelector("#promptForm");
+
+ window.prompt = function (promptText = "Please provide an input", options = {default: 1, step: 0.01, min: 0, max: 100}, callback) {
+ if (options.default === undefined) {
+ ERROR && console.error("Prompt: options object does not have default value defined");
+ return;
+ }
+ const input = prompt.querySelector("#promptInput");
+ prompt.querySelector("#promptText").innerHTML = promptText;
+ const type = typeof options.default === "number" ? "number" : "text";
+ input.type = type;
+ if (options.step !== undefined) input.step = options.step;
+ if (options.min !== undefined) input.min = options.min;
+ if (options.max !== undefined) input.max = options.max;
+ input.placeholder = "type a " + type;
+ input.value = options.default;
+ prompt.style.display = "block";
+
+ form.addEventListener(
+ "submit",
+ event => {
+ prompt.style.display = "none";
+ const v = type === "number" ? +input.value : input.value;
+ event.preventDefault();
+ if (callback) callback(v);
+ },
+ {once: true}
+ );
+ };
+
+ const cancel = prompt.querySelector("#promptCancel");
+ cancel.addEventListener("click", () => (prompt.style.display = "none"));
+})();
+
+// indexedDB; ldb object
+void (function () {
+ function e(t, o) {
+ return n
+ ? void (n.transaction("s").objectStore("s").get(t).onsuccess = function (e) {
+ var t = (e.target.result && e.target.result.v) || null;
+ o(t);
+ })
+ : void setTimeout(function () {
+ e(t, o);
+ }, 100);
+ }
+ var t = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
+ if (!t) return void ERROR && console.error("indexedDB not supported");
+ var n,
+ o = {k: "", v: ""},
+ r = t.open("d2", 1);
+ (r.onsuccess = function (e) {
+ n = this.result;
+ }),
+ (r.onerror = function (e) {
+ ERROR && console.error("indexedDB request error"), INFO && console.log(e);
+ }),
+ (r.onupgradeneeded = function (e) {
+ n = null;
+ var t = e.target.result.createObjectStore("s", {keyPath: "k"});
+ t.transaction.oncomplete = function (e) {
+ n = e.target.db;
+ };
+ }),
+ (window.ldb = {
+ get: e,
+ set: function (e, t) {
+ (o.k = e), (o.v = t), n.transaction("s", "readwrite").objectStore("s").put(o);
+ }
+ });
+})();
diff --git a/utils/graphUtils.js b/utils/graphUtils.js
new file mode 100644
index 00000000..3814241f
--- /dev/null
+++ b/utils/graphUtils.js
@@ -0,0 +1,276 @@
+"use strict";
+// FMG utils related to graph
+
+// add boundary points to pseudo-clip voronoi cells
+function getBoundaryPoints(width, height, spacing) {
+ const offset = rn(-1 * spacing);
+ const bSpacing = spacing * 2;
+ const w = width - offset * 2;
+ const h = height - offset * 2;
+ const numberX = Math.ceil(w / bSpacing) - 1;
+ const numberY = Math.ceil(h / bSpacing) - 1;
+ let points = [];
+ for (let i = 0.5; i < numberX; i++) {
+ let x = Math.ceil((w * i) / numberX + offset);
+ points.push([x, offset], [x, h + offset]);
+ }
+ for (let i = 0.5; i < numberY; i++) {
+ let y = Math.ceil((h * i) / numberY + offset);
+ points.push([offset, y], [w + offset, y]);
+ }
+ return points;
+}
+
+// get points on a regular square grid and jitter them a bit
+function getJitteredGrid(width, height, spacing) {
+ const radius = spacing / 2; // square radius
+ const jittering = radius * 0.9; // max deviation
+ const doubleJittering = jittering * 2;
+ const jitter = () => Math.random() * doubleJittering - jittering;
+
+ let points = [];
+ for (let y = radius; y < height; y += spacing) {
+ for (let x = radius; x < width; x += spacing) {
+ const xj = Math.min(rn(x + jitter(), 2), width);
+ const yj = Math.min(rn(y + jitter(), 2), height);
+ points.push([xj, yj]);
+ }
+ }
+ return points;
+}
+
+// return cell index on a regular square grid
+function findGridCell(x, y) {
+ return Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1));
+}
+
+// return array of cell indexes in radius on a regular square grid
+function findGridAll(x, y, radius) {
+ const c = grid.cells.c;
+ let r = Math.floor(radius / grid.spacing);
+ let found = [findGridCell(x, y)];
+ if (!r || radius === 1) return found;
+ if (r > 0) found = found.concat(c[found[0]]);
+ if (r > 1) {
+ let frontier = c[found[0]];
+ while (r > 1) {
+ let cycle = frontier.slice();
+ frontier = [];
+ cycle.forEach(function (s) {
+ c[s].forEach(function (e) {
+ if (found.indexOf(e) !== -1) return;
+ found.push(e);
+ frontier.push(e);
+ });
+ });
+ r--;
+ }
+ }
+
+ return found;
+}
+
+// return closest pack points quadtree datum
+function find(x, y, radius = Infinity) {
+ return pack.cells.q.find(x, y, radius);
+}
+
+// return closest cell index
+function findCell(x, y, radius = Infinity) {
+ const found = pack.cells.q.find(x, y, radius);
+ return found ? found[2] : undefined;
+}
+
+// return array of cell indexes in radius
+function findAll(x, y, radius) {
+ const found = pack.cells.q.findAll(x, y, radius);
+ return found.map(r => r[2]);
+}
+
+// get polygon points for packed cells knowing cell id
+function getPackPolygon(i) {
+ return pack.cells.v[i].map(v => pack.vertices.p[v]);
+}
+
+// get polygon points for initial cells knowing cell id
+function getGridPolygon(i) {
+ return grid.cells.v[i].map(v => grid.vertices.p[v]);
+}
+
+// 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;
+ }
+}
+
+// filter land cells
+function isLand(i) {
+ return pack.cells.h[i] >= 20;
+}
+
+// filter water cells
+function isWater(i) {
+ return pack.cells.h[i] < 20;
+}
+
+// findAll d3.quandtree search from https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
+void (function addFindAll() {
+ const Quad = function (node, x0, y0, x1, y1) {
+ this.node = node;
+ this.x0 = x0;
+ this.y0 = y0;
+ this.x1 = x1;
+ this.y1 = y1;
+ };
+
+ const tree_filter = function (x, y, radius) {
+ var t = {x, y, x0: this._x0, y0: this._y0, x3: this._x1, y3: this._y1, quads: [], node: this._root};
+ if (t.node) {
+ t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
+ }
+ radiusSearchInit(t, radius);
+
+ var i = 0;
+ while ((t.q = t.quads.pop())) {
+ i++;
+
+ // Stop searching if this quadrant can’t contain a closer node.
+ if (!(t.node = t.q.node) || (t.x1 = t.q.x0) > t.x3 || (t.y1 = t.q.y0) > t.y3 || (t.x2 = t.q.x1) < t.x0 || (t.y2 = t.q.y1) < t.y0) continue;
+
+ // Bisect the current quadrant.
+ if (t.node.length) {
+ t.node.explored = true;
+ var xm = (t.x1 + t.x2) / 2,
+ ym = (t.y1 + t.y2) / 2;
+
+ t.quads.push(
+ new Quad(t.node[3], xm, ym, t.x2, t.y2),
+ new Quad(t.node[2], t.x1, ym, xm, t.y2),
+ new Quad(t.node[1], xm, t.y1, t.x2, ym),
+ new Quad(t.node[0], t.x1, t.y1, xm, ym)
+ );
+
+ // Visit the closest quadrant first.
+ if ((t.i = ((y >= ym) << 1) | (x >= xm))) {
+ t.q = t.quads[t.quads.length - 1];
+ t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
+ t.quads[t.quads.length - 1 - t.i] = t.q;
+ }
+ }
+
+ // Visit this point. (Visiting coincident points isn’t necessary!)
+ else {
+ var dx = x - +this._x.call(null, t.node.data),
+ dy = y - +this._y.call(null, t.node.data),
+ d2 = dx * dx + dy * dy;
+ radiusSearchVisit(t, d2);
+ }
+ }
+ return t.result;
+ };
+ d3.quadtree.prototype.findAll = tree_filter;
+
+ var radiusSearchInit = function (t, radius) {
+ t.result = [];
+ (t.x0 = t.x - radius), (t.y0 = t.y - radius);
+ (t.x3 = t.x + radius), (t.y3 = t.y + radius);
+ t.radius = radius * radius;
+ };
+
+ var radiusSearchVisit = function (t, d2) {
+ t.node.data.scanned = true;
+ if (d2 < t.radius) {
+ do {
+ t.result.push(t.node.data);
+ t.node.data.selected = true;
+ } while ((t.node = t.node.next));
+ }
+ };
+})();
+
+// helper function non-used for the generation
+function drawCellsValue(data) {
+ debug.selectAll("text").remove();
+ debug
+ .selectAll("text")
+ .data(data)
+ .enter()
+ .append("text")
+ .attr("x", (d, i) => pack.cells.p[i][0])
+ .attr("y", (d, i) => pack.cells.p[i][1])
+ .text(d => d);
+}
+
+// helper function non-used for the generation
+function drawPolygons(data) {
+ const max = d3.max(data),
+ min = d3.min(data),
+ scheme = getColorScheme();
+ data = data.map(d => 1 - normalize(d, min, max));
+
+ debug.selectAll("polygon").remove();
+ debug
+ .selectAll("polygon")
+ .data(data)
+ .enter()
+ .append("polygon")
+ .attr("points", (d, i) => getPackPolygon(i))
+ .attr("fill", d => scheme(d))
+ .attr("stroke", d => scheme(d));
+}
diff --git a/utils/nodeUtils.js b/utils/nodeUtils.js
new file mode 100644
index 00000000..0010f3d8
--- /dev/null
+++ b/utils/nodeUtils.js
@@ -0,0 +1,30 @@
+"use strict";
+// FMG utils related to nodes
+
+// remove parent element (usually if child is clicked)
+function removeParent() {
+ this.parentNode.parentNode.removeChild(this.parentNode);
+}
+
+// polyfill for composedPath
+function getComposedPath(node) {
+ let parent;
+ if (node.parentNode) parent = node.parentNode;
+ else if (node.host) parent = node.host;
+ else if (node.defaultView) parent = node.defaultView;
+ if (parent !== undefined) return [node].concat(getComposedPath(parent));
+ return [node];
+}
+
+// get next unused id
+function getNextId(core, i = 1) {
+ while (document.getElementById(core + i)) i++;
+ return core + i;
+}
+
+function getAbsolutePath(href) {
+ if (!href) return "";
+ const link = document.createElement("a");
+ link.href = href;
+ return link.href;
+}
diff --git a/utils/numberUtils.js b/utils/numberUtils.js
new file mode 100644
index 00000000..e3f143a5
--- /dev/null
+++ b/utils/numberUtils.js
@@ -0,0 +1,22 @@
+"use strict";
+// FMG utils related to numbers
+
+// round value to d decimals
+function rn(v, d = 0) {
+ const m = Math.pow(10, d);
+ return Math.round(v * m) / m;
+}
+
+function minmax(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
+
+// return value in range [0, 100]
+function lim(v) {
+ return minmax(v, 0, 100);
+}
+
+// normalization function
+function normalize(val, min, max) {
+ return minmax((val - min) / (max - min), 0, 1);
+}
diff --git a/utils/polyfills.js b/utils/polyfills.js
new file mode 100644
index 00000000..369e647f
--- /dev/null
+++ b/utils/polyfills.js
@@ -0,0 +1,16 @@
+"use strict";
+
+// replaceAll
+if (String.prototype.replaceAll === undefined) {
+ String.prototype.replaceAll = function (str, newStr) {
+ if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str, newStr);
+ return this.replace(new RegExp(str, "g"), newStr);
+ };
+}
+
+// flat
+if (Array.prototype.flat === undefined) {
+ Array.prototype.flat = function () {
+ return this.reduce((acc, val) => (Array.isArray(val) ? acc.concat(val.flat()) : acc.concat(val)), []);
+ };
+}
diff --git a/utils/probabilityUtils.js b/utils/probabilityUtils.js
new file mode 100644
index 00000000..454b659c
--- /dev/null
+++ b/utils/probabilityUtils.js
@@ -0,0 +1,76 @@
+"use strict";
+// FMG utils related to randomness
+
+// random number in a range
+function rand(min, max) {
+ if (min === undefined && max === undefined) return Math.random();
+ if (max === undefined) {
+ max = min;
+ min = 0;
+ }
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+// probability shorthand
+function P(probability) {
+ if (probability >= 1) return true;
+ if (probability <= 0) return false;
+ return Math.random() < probability;
+}
+
+function each(n) {
+ return i => i % n === 0;
+}
+
+// random number (normal or gaussian distribution)
+function gauss(expected = 100, deviation = 30, min = 0, max = 300, round = 0) {
+ return rn(minmax(d3.randomNormal(expected, deviation)(), min, max), round);
+}
+
+// probability shorthand for floats
+function Pint(float) {
+ return ~~float + +P(float % 1);
+}
+
+// return random value from the array
+function ra(array) {
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+// return random value from weighted array {"key1":weight1, "key2":weight2}
+function rw(object) {
+ const array = [];
+ for (const key in object) {
+ for (let i = 0; i < object[key]; i++) {
+ array.push(key);
+ }
+ }
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+// return a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min)
+function biased(min, max, ex) {
+ return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
+}
+
+// get number from string in format "1-3" or "2" or "0.5"
+function getNumberInRange(r) {
+ if (typeof r !== "string") {
+ ERROR && console.error("The value should be a string", r);
+ return 0;
+ }
+ if (!isNaN(+r)) return ~~r + +P(r - ~~r);
+ const sign = r[0] === "-" ? -1 : 1;
+ if (isNaN(+r[0])) r = r.slice(1);
+ const range = r.includes("-") ? r.split("-") : null;
+ if (!range) {
+ ERROR && console.error("Cannot parse the number. Check the format", r);
+ return 0;
+ }
+ const count = rand(range[0] * sign, +range[1]);
+ if (isNaN(count) || count < 0) {
+ ERROR && console.error("Cannot parse number. Check the format", r);
+ return 0;
+ }
+ return count;
+}
diff --git a/utils/stringUtils.js b/utils/stringUtils.js
new file mode 100644
index 00000000..eddc88f6
--- /dev/null
+++ b/utils/stringUtils.js
@@ -0,0 +1,116 @@
+"use strict";
+// FMG utils related to strings
+
+// round numbers in string to d decimals
+function round(s, d = 1) {
+ return s.replace(/[\d\.-][\d\.e-]*/g, function (n) {
+ return rn(n, d);
+ });
+}
+
+// return string with 1st char capitalized
+function capitalize(string) {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+}
+
+// check if char is vowel or can serve as vowel
+function vowel(c) {
+ return `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`.includes(c);
+}
+
+// remove vowels from the end of the string
+function trimVowels(string) {
+ while (string.length > 3 && vowel(last(string))) {
+ string = string.slice(0, -1);
+ }
+ return string;
+}
+
+// get adjective form from noun
+function getAdjective(string) {
+ // special cases for some suffixes
+ if (string.length > 8 && string.slice(-6) === "orszag") return string.slice(0, -6);
+ if (string.length > 6 && string.slice(-4) === "stan") return string.slice(0, -4);
+ if (P(0.5) && string.slice(-4) === "land") return string + "ic";
+ if (string.slice(-4) === " Guo") string = string.slice(0, -4);
+
+ // don't change is name ends on suffix
+ if (string.slice(-2) === "an") return string;
+ if (string.slice(-3) === "ese") return string;
+ if (string.slice(-1) === "i") return string;
+
+ const end = string.slice(-1); // last letter of string
+ if (end === "a") return (string += "n");
+ if (end === "o") return (string = trimVowels(string) + "an");
+ if (vowel(end) || end === "c") return (string += "an"); // ceiuy
+ if (end === "m" || end === "n") return (string += "ese");
+ if (end === "q") return (string += "i");
+ return trimVowels(string) + "ian";
+}
+
+// get ordinal out of integer: 1 => 1st
+const nth = n => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th");
+
+// get two-letters code (abbreviation) from string
+function abbreviate(name, restricted = []) {
+ const parsed = name.replace("Old ", "O ").replace(/[()]/g, ""); // remove Old prefix and parentheses
+ const words = parsed.split(" ");
+ const letters = words.join("");
+
+ let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2);
+ for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) {
+ code = letters[0] + letters[i].toUpperCase();
+ }
+ return code;
+}
+
+// conjunct array: [A,B,C] => "A, B and C"
+function list(array) {
+ if (!Intl.ListFormat) return array.join(", ");
+ const conjunction = new Intl.ListFormat(window.lang || "en", {style: "long", type: "conjunction"});
+ return conjunction.format(array);
+}
+
+// split string into 2 almost equal parts not breaking words
+function splitInTwo(str) {
+ const half = str.length / 2;
+ const ar = str.split(" ");
+ if (ar.length < 2) return ar; // only one word
+ let first = "",
+ last = "",
+ middle = "",
+ rest = "";
+
+ ar.forEach((w, d) => {
+ if (d + 1 !== ar.length) w += " ";
+ rest += w;
+ if (!first || rest.length < half) first += w;
+ else if (!middle) middle = w;
+ else last += w;
+ });
+
+ if (!last) return [first, middle];
+ if (first.length < last.length) return [first + middle, last];
+ return [first, middle + last];
+}
+
+// transform string to array [translateX,translateY,rotateDeg,rotateX,rotateY,scale]
+function parseTransform(string) {
+ if (!string) return [0, 0, 0, 0, 0, 1];
+
+ const a = string
+ .replace(/[a-z()]/g, "")
+ .replace(/[ ]/g, ",")
+ .split(",");
+ return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1];
+}
+
+// check if string is a valid for JSON parse
+JSON.isValid = str => {
+ try {
+ JSON.parse(str);
+ } catch (e) {
+ return false;
+ }
+ return true;
+};
diff --git a/utils/unitUtils.js b/utils/unitUtils.js
new file mode 100644
index 00000000..609b5eb9
--- /dev/null
+++ b/utils/unitUtils.js
@@ -0,0 +1,45 @@
+"use strict";
+// FMG utils related to units
+
+// conver temperature from °C to other scales
+function convertTemperature(temp) {
+ switch (temperatureScale.value) {
+ case "°C":
+ return temp + "°C";
+ case "°F":
+ return rn((temp * 9) / 5 + 32) + "°F";
+ case "K":
+ return rn(temp + 273.15) + "K";
+ case "°R":
+ return rn(((temp + 273.15) * 9) / 5) + "°R";
+ case "°De":
+ return rn(((100 - temp) * 3) / 2) + "°De";
+ case "°N":
+ return rn((temp * 33) / 100) + "°N";
+ case "°Ré":
+ return rn((temp * 4) / 5) + "°Ré";
+ case "°Rø":
+ return rn((temp * 21) / 40 + 7.5) + "°Rø";
+ default:
+ return temp + "°C";
+ }
+}
+
+// corvent number to short string with SI postfix
+function si(n) {
+ if (n >= 1e9) return rn(n / 1e9, 1) + "B";
+ if (n >= 1e8) return rn(n / 1e6) + "M";
+ if (n >= 1e6) return rn(n / 1e6, 1) + "M";
+ if (n >= 1e4) return rn(n / 1e3) + "K";
+ if (n >= 1e3) return rn(n / 1e3, 1) + "K";
+ return rn(n);
+}
+
+// getInteger number from user input data
+function getInteger(value) {
+ const metric = value.slice(-1);
+ if (metric === "K") return parseInt(value.slice(0, -1) * 1e3);
+ if (metric === "M") return parseInt(value.slice(0, -1) * 1e6);
+ if (metric === "B") return parseInt(value.slice(0, -1) * 1e9);
+ return parseInt(value);
+}