mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
migrating all util files from js to ts
This commit is contained in:
parent
76f86497c7
commit
fa493989b6
39 changed files with 3174 additions and 1523 deletions
|
|
@ -13,12 +13,6 @@ const ERROR = true;
|
|||
// detect device
|
||||
const MOBILE = window.innerWidth < 600 || navigator.userAgentData?.mobile;
|
||||
|
||||
// typed arrays max values
|
||||
const INT8_MAX = 127;
|
||||
const UINT8_MAX = 255;
|
||||
const UINT16_MAX = 65535;
|
||||
const UINT32_MAX = 4294967295;
|
||||
|
||||
if (PRODUCTION && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("./sw.js").catch(err => {
|
||||
|
|
@ -91,7 +85,7 @@ let fogging = viewbox
|
|||
.attr("id", "fogging")
|
||||
.style("display", "none");
|
||||
let ruler = viewbox.append("g").attr("id", "ruler").style("display", "none");
|
||||
let debug = viewbox.append("g").attr("id", "debug");
|
||||
var debug = viewbox.append("g").attr("id", "debug");
|
||||
|
||||
lakes.append("g").attr("id", "freshwater");
|
||||
lakes.append("g").attr("id", "salt");
|
||||
|
|
@ -140,9 +134,9 @@ legend
|
|||
.on("click", () => clearLegend());
|
||||
|
||||
// main data variables
|
||||
let grid = {}; // initial graph based on jittered square grid and data
|
||||
let pack = {}; // packed graph and data
|
||||
let seed;
|
||||
var grid = {}; // initial graph based on jittered square grid and data
|
||||
var pack = {}; // packed graph and data
|
||||
var seed;
|
||||
let mapId;
|
||||
let mapHistory = [];
|
||||
let elSelected;
|
||||
|
|
@ -202,8 +196,8 @@ let urbanDensity = +byId("urbanDensityInput").value;
|
|||
applyStoredOptions();
|
||||
|
||||
// voronoi graph extension, cannot be changed after generation
|
||||
let graphWidth = +mapWidthInput.value;
|
||||
let graphHeight = +mapHeightInput.value;
|
||||
var graphWidth = +mapWidthInput.value;
|
||||
var graphHeight = +mapHeightInput.value;
|
||||
|
||||
// svg canvas resolution, can be changed
|
||||
let svgWidth = graphWidth;
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ window.Military = (function () {
|
|||
}
|
||||
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);
|
||||
const candidates = findAllInQuadtree(node.x, node.y, r, tree);
|
||||
for (const c of candidates) {
|
||||
if (c.t < expected && mergeable(node, c)) {
|
||||
merge(node, c);
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ function editReliefIcon() {
|
|||
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());
|
||||
findAllInQuadtree(p[0], p[1], r, tree).forEach(f => f[2].remove());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,3 +133,5 @@ class Voronoi {
|
|||
];
|
||||
}
|
||||
}
|
||||
|
||||
window.Voronoi = Voronoi;
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
function last(array) {
|
||||
return array[array.length - 1];
|
||||
}
|
||||
|
||||
function unique(array) {
|
||||
return [...new Set(array)];
|
||||
}
|
||||
|
||||
// deep copy for Arrays (and other objects)
|
||||
function deepCopy(obj) {
|
||||
const id = x => x;
|
||||
const dcTArray = a => a.map(id);
|
||||
const dcObject = x => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)]));
|
||||
const dcAny = x => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x);
|
||||
// don't map keys, probably this is what we would expect
|
||||
const dcMapCore = m => [...m.entries()].map(([k, v]) => [k, dcAny(v)]);
|
||||
|
||||
const cf = new Map([
|
||||
[Int8Array, dcTArray],
|
||||
[Uint8Array, dcTArray],
|
||||
[Uint8ClampedArray, dcTArray],
|
||||
[Int16Array, dcTArray],
|
||||
[Uint16Array, dcTArray],
|
||||
[Int32Array, dcTArray],
|
||||
[Uint32Array, dcTArray],
|
||||
[Float32Array, dcTArray],
|
||||
[Float64Array, dcTArray],
|
||||
[BigInt64Array, dcTArray],
|
||||
[BigUint64Array, dcTArray],
|
||||
[Map, m => new Map(dcMapCore(m))],
|
||||
[WeakMap, m => new WeakMap(dcMapCore(m))],
|
||||
[Array, a => a.map(dcAny)],
|
||||
[Set, s => [...s.values()].map(dcAny)],
|
||||
[Date, d => new Date(d.getTime())],
|
||||
[Object, dcObject]
|
||||
// ... extend here to implement their custom deep copy
|
||||
]);
|
||||
|
||||
return dcAny(obj);
|
||||
}
|
||||
|
||||
function getTypedArray(maxValue) {
|
||||
console.assert(
|
||||
Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= UINT32_MAX,
|
||||
`Array maxValue must be an integer between 0 and ${UINT32_MAX}, got ${maxValue}`
|
||||
);
|
||||
|
||||
if (maxValue <= UINT8_MAX) return Uint8Array;
|
||||
if (maxValue <= UINT16_MAX) return Uint16Array;
|
||||
if (maxValue <= UINT32_MAX) return Uint32Array;
|
||||
return Uint32Array;
|
||||
}
|
||||
|
||||
function createTypedArray({maxValue, length, from}) {
|
||||
const typedArray = getTypedArray(maxValue);
|
||||
if (!from) return new typedArray(length);
|
||||
return typedArray.from(from);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"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)
|
||||
: "";
|
||||
}
|
||||
|
||||
const C_12 = [
|
||||
"#dababf",
|
||||
"#fb8072",
|
||||
"#80b1d3",
|
||||
"#fdb462",
|
||||
"#b3de69",
|
||||
"#fccde5",
|
||||
"#c6b9c1",
|
||||
"#bc80bd",
|
||||
"#ccebc5",
|
||||
"#ffed6f",
|
||||
"#8dd3c7",
|
||||
"#eb8de7"
|
||||
];
|
||||
const scaleRainbow = d3.scaleSequential(d3.interpolateRainbow);
|
||||
|
||||
// return array of standard shuffled colors
|
||||
function getColors(number) {
|
||||
const colors = d3.shuffle(
|
||||
d3.range(number).map(i => (i < 12 ? C_12[i] : d3.color(scaleRainbow((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();
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
"use strict";
|
||||
// FMG helper functions
|
||||
|
||||
// clip polygon by graph bbox
|
||||
function clipPoly(points, secure = 0) {
|
||||
if (points.length < 2) return points;
|
||||
if (points.some(point => point === undefined)) {
|
||||
ERROR && console.error("Undefined point in clipPoly", points);
|
||||
return points;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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(dist2(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 dist = dist2(point, [x, y]);
|
||||
|
||||
if (dist >= minDist) continue;
|
||||
minDist = dist;
|
||||
minSegment = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return minSegment;
|
||||
}
|
||||
|
||||
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 => "<i>" + last(url.split("/")) + "</i>");
|
||||
const errorParsed = errorNoURL.replace(/at /gi, "<br> 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 `<a href="${URL}" rel="noopener" target="_blank">${description}</a>`;
|
||||
}
|
||||
|
||||
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 getLongitude(x, decimals = 2) {
|
||||
return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals);
|
||||
}
|
||||
|
||||
function getLatitude(y, decimals = 2) {
|
||||
return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals);
|
||||
}
|
||||
|
||||
function getCoordinates(x, y, decimals = 2) {
|
||||
return [getLongitude(x, decimals), getLatitude(y, decimals)];
|
||||
}
|
||||
|
||||
// prompt replacer (prompt does not work in Electron)
|
||||
void (function () {
|
||||
const prompt = document.getElementById("prompt");
|
||||
const form = prompt.querySelector("#promptForm");
|
||||
|
||||
const defaultText = "Please provide an input";
|
||||
const defaultOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true};
|
||||
|
||||
window.prompt = function (promptText = defaultText, options = defaultOptions, callback) {
|
||||
if (options.default === undefined)
|
||||
return ERROR && console.error("Prompt: options object does not have default value defined");
|
||||
|
||||
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.required = options.required === false ? false : true;
|
||||
input.placeholder = "type a " + type;
|
||||
input.value = options.default;
|
||||
input.style.width = promptText.length > 10 ? "100%" : "auto";
|
||||
prompt.style.display = "block";
|
||||
|
||||
form.addEventListener(
|
||||
"submit",
|
||||
event => {
|
||||
event.preventDefault();
|
||||
prompt.style.display = "none";
|
||||
const v = type === "number" ? +input.value : input.value;
|
||||
if (callback) callback(v);
|
||||
},
|
||||
{once: true}
|
||||
);
|
||||
};
|
||||
|
||||
const cancel = prompt.querySelector("#promptCancel");
|
||||
cancel.addEventListener("click", () => {
|
||||
prompt.style.display = "none";
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"use strict";
|
||||
// FMG utils used for debugging
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function drawPolygons(data) {
|
||||
const max = d3.max(data);
|
||||
const min = d3.min(data);
|
||||
const scheme = getColorScheme(terrs.select("#landHeights").attr("scheme"));
|
||||
|
||||
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) => getGridPolygon(i))
|
||||
.attr("fill", d => scheme(d))
|
||||
.attr("stroke", d => scheme(d));
|
||||
}
|
||||
|
||||
function drawRouteConnections() {
|
||||
debug.select("#connections").remove();
|
||||
const routes = debug.append("g").attr("id", "connections").attr("stroke-width", 0.8);
|
||||
|
||||
const points = pack.cells.p;
|
||||
const links = pack.cells.routes;
|
||||
|
||||
for (const from in links) {
|
||||
for (const to in links[from]) {
|
||||
const [x1, y1] = points[from];
|
||||
const [x3, y3] = points[to];
|
||||
const [x2, y2] = [(x1 + x3) / 2, (y1 + y3) / 2];
|
||||
const routeId = links[from][to];
|
||||
|
||||
routes
|
||||
.append("line")
|
||||
.attr("x1", x1)
|
||||
.attr("y1", y1)
|
||||
.attr("x2", x2)
|
||||
.attr("y2", y2)
|
||||
.attr("data-id", routeId)
|
||||
.attr("stroke", C_12[routeId % 12]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPoint([x, y], {color = "red", radius = 0.5}) {
|
||||
debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color);
|
||||
}
|
||||
|
||||
function drawPath(points, {color = "red", width = 0.5}) {
|
||||
const lineGen = d3.line().curve(d3.curveBundle);
|
||||
debug
|
||||
.append("path")
|
||||
.attr("d", round(lineGen(points)))
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", width)
|
||||
.attr("fill", "none");
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"use strict";
|
||||
// FMG helper functions
|
||||
|
||||
// extracted d3 code to bypass version conflicts
|
||||
// https://github.com/d3/d3-array/blob/main/src/group.js
|
||||
function rollups(values, reduce, ...keys) {
|
||||
return nest(values, Array.from, reduce, keys);
|
||||
}
|
||||
|
||||
function nest(values, map, reduce, keys) {
|
||||
return (function regroup(values, i) {
|
||||
if (i >= keys.length) return reduce(values);
|
||||
const groups = new Map();
|
||||
const keyof = keys[i++];
|
||||
let index = -1;
|
||||
for (const value of values) {
|
||||
const key = keyof(value, ++index, values);
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(value);
|
||||
else groups.set(key, [value]);
|
||||
}
|
||||
for (const [key, values] of groups) {
|
||||
groups.set(key, regroup(values, i));
|
||||
}
|
||||
return map(groups);
|
||||
})(values, 0);
|
||||
}
|
||||
|
||||
function dist2([x1, y1], [x2, y2]) {
|
||||
return (x1 - x2) ** 2 + (y1 - y2) ** 2;
|
||||
}
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
"use strict";
|
||||
// FMG utils related to graph
|
||||
|
||||
// check if new grid graph should be generated or we can use the existing one
|
||||
function shouldRegenerateGrid(grid, expectedSeed) {
|
||||
if (expectedSeed && expectedSeed !== grid.seed) return true;
|
||||
|
||||
const cellsDesired = +byId("pointsInput").dataset.cells;
|
||||
if (cellsDesired !== grid.cellsDesired) return true;
|
||||
|
||||
const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2);
|
||||
const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing);
|
||||
const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing);
|
||||
|
||||
return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY;
|
||||
}
|
||||
|
||||
function generateGrid() {
|
||||
Math.random = aleaPRNG(seed); // reset PRNG
|
||||
const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints();
|
||||
const {cells, vertices} = calculateVoronoi(points, boundary);
|
||||
return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed};
|
||||
}
|
||||
|
||||
// place random points to calculate Voronoi diagram
|
||||
function placePoints() {
|
||||
TIME && console.time("placePoints");
|
||||
const cellsDesired = +byId("pointsInput").dataset.cells;
|
||||
const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jirrering
|
||||
|
||||
const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
|
||||
const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid
|
||||
const cellsX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing);
|
||||
const cellsY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing);
|
||||
TIME && console.timeEnd("placePoints");
|
||||
|
||||
return {spacing, cellsDesired, boundary, points, cellsX, cellsY};
|
||||
}
|
||||
|
||||
// calculate Delaunay and then Voronoi diagram
|
||||
function calculateVoronoi(points, boundary) {
|
||||
TIME && console.time("calculateDelaunay");
|
||||
const allPoints = points.concat(boundary);
|
||||
const delaunay = Delaunator.from(allPoints);
|
||||
TIME && console.timeEnd("calculateDelaunay");
|
||||
|
||||
TIME && console.time("calculateVoronoi");
|
||||
const voronoi = new Voronoi(delaunay, allPoints, points.length);
|
||||
|
||||
const cells = voronoi.cells;
|
||||
cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i); // array of indexes
|
||||
const vertices = voronoi.vertices;
|
||||
TIME && console.timeEnd("calculateVoronoi");
|
||||
|
||||
return {cells, vertices};
|
||||
}
|
||||
|
||||
// add points along map edge 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;
|
||||
const 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, grid) {
|
||||
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, grid)];
|
||||
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) {
|
||||
if (!pack.cells?.q) return;
|
||||
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) {
|
||||
const 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));
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// draw raster heightmap preview (not used in main generation)
|
||||
function drawHeights({heights, width, height, scheme, renderOcean}) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// chars that serve as vowels
|
||||
const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`;
|
||||
function vowel(c) {
|
||||
return VOWELS.includes(c);
|
||||
}
|
||||
|
||||
// remove vowels from the end of the string
|
||||
function trimVowels(string, minLength = 3) {
|
||||
while (string.length > minLength && vowel(last(string))) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
const adjectivizationRules = [
|
||||
{name: "guo", probability: 1, condition: new RegExp(" Guo$"), action: noun => noun.slice(0, -4)},
|
||||
{
|
||||
name: "orszag",
|
||||
probability: 1,
|
||||
condition: new RegExp("orszag$"),
|
||||
action: noun => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6))
|
||||
},
|
||||
{
|
||||
name: "stan",
|
||||
probability: 1,
|
||||
condition: new RegExp("stan$"),
|
||||
action: noun => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4)))
|
||||
},
|
||||
{
|
||||
name: "land",
|
||||
probability: 1,
|
||||
condition: new RegExp("land$"),
|
||||
action: noun => {
|
||||
if (noun.length > 9) return noun.slice(0, -4);
|
||||
const root = trimVowels(noun.slice(0, -4), 0);
|
||||
if (root.length < 3) return noun + "ic";
|
||||
if (root.length < 4) return root + "lish";
|
||||
return root + "ish";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "que",
|
||||
probability: 1,
|
||||
condition: new RegExp("que$"),
|
||||
action: noun => noun.replace(/que$/, "can")
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
probability: 1,
|
||||
condition: new RegExp("a$"),
|
||||
action: noun => noun + "n"
|
||||
},
|
||||
{
|
||||
name: "o",
|
||||
probability: 1,
|
||||
condition: new RegExp("o$"),
|
||||
action: noun => noun.replace(/o$/, "an")
|
||||
},
|
||||
{
|
||||
name: "u",
|
||||
probability: 1,
|
||||
condition: new RegExp("u$"),
|
||||
action: noun => noun + "an"
|
||||
},
|
||||
{
|
||||
name: "i",
|
||||
probability: 1,
|
||||
condition: new RegExp("i$"),
|
||||
action: noun => noun + "an"
|
||||
},
|
||||
{
|
||||
name: "e",
|
||||
probability: 1,
|
||||
condition: new RegExp("e$"),
|
||||
action: noun => noun + "an"
|
||||
},
|
||||
{
|
||||
name: "ay",
|
||||
probability: 1,
|
||||
condition: new RegExp("ay$"),
|
||||
action: noun => noun + "an"
|
||||
},
|
||||
{
|
||||
name: "os",
|
||||
probability: 1,
|
||||
condition: new RegExp("os$"),
|
||||
action: noun => {
|
||||
const root = trimVowels(noun.slice(0, -2), 0);
|
||||
if (root.length < 4) return noun.slice(0, -1);
|
||||
return root + "ian";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "es",
|
||||
probability: 1,
|
||||
condition: new RegExp("es$"),
|
||||
action: noun => {
|
||||
const root = trimVowels(noun.slice(0, -2), 0);
|
||||
if (root.length > 7) return noun.slice(0, -1);
|
||||
return root + "ian";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "l",
|
||||
probability: 0.8,
|
||||
condition: new RegExp("l$"),
|
||||
action: noun => noun + "ese"
|
||||
},
|
||||
{
|
||||
name: "n",
|
||||
probability: 0.8,
|
||||
condition: new RegExp("n$"),
|
||||
action: noun => noun + "ese"
|
||||
},
|
||||
{
|
||||
name: "ad",
|
||||
probability: 0.8,
|
||||
condition: new RegExp("ad$"),
|
||||
action: noun => noun + "ian"
|
||||
},
|
||||
{
|
||||
name: "an",
|
||||
probability: 0.8,
|
||||
condition: new RegExp("an$"),
|
||||
action: noun => noun + "ian"
|
||||
},
|
||||
{
|
||||
name: "ish",
|
||||
probability: 0.25,
|
||||
condition: new RegExp("^[a-zA-Z]{6}$"),
|
||||
action: noun => trimVowels(noun.slice(0, -1)) + "ish"
|
||||
},
|
||||
{
|
||||
name: "an",
|
||||
probability: 0.5,
|
||||
condition: new RegExp("^[a-zA-Z]{0,7}$"),
|
||||
action: noun => trimVowels(noun) + "an"
|
||||
}
|
||||
];
|
||||
|
||||
// get adjective form from noun
|
||||
function getAdjective(noun) {
|
||||
for (const rule of adjectivizationRules) {
|
||||
if (P(rule.probability) && rule.condition.test(noun)) {
|
||||
return rule.action(noun);
|
||||
}
|
||||
}
|
||||
return noun; // no rule applied, return noun as is
|
||||
}
|
||||
|
||||
// get ordinal from 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);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"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;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
"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);
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// get continuous paths (isolines) for all cells at once based on getType(cellId) comparison
|
||||
function getIsolines(graph, getType, options = {polygons: false, fill: false, halo: false, waterGap: false}) {
|
||||
const {cells, vertices} = graph;
|
||||
const isolines = {};
|
||||
|
||||
const checkedCells = new Uint8Array(cells.i.length);
|
||||
const addToChecked = cellId => (checkedCells[cellId] = 1);
|
||||
const isChecked = cellId => checkedCells[cellId] === 1;
|
||||
|
||||
for (const cellId of cells.i) {
|
||||
if (isChecked(cellId) || !getType(cellId)) continue;
|
||||
addToChecked(cellId);
|
||||
|
||||
const type = getType(cellId);
|
||||
const ofSameType = cellId => getType(cellId) === type;
|
||||
const ofDifferentType = cellId => getType(cellId) !== type;
|
||||
|
||||
const onborderCell = cells.c[cellId].find(ofDifferentType);
|
||||
if (onborderCell === undefined) continue;
|
||||
|
||||
// check if inner lake. Note there is no shoreline for grid features
|
||||
const feature = graph.features[cells.f[onborderCell]];
|
||||
if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue;
|
||||
|
||||
const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
|
||||
if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
|
||||
|
||||
const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
|
||||
if (vertexChain.length < 3) continue;
|
||||
|
||||
addIsoline(type, vertices, vertexChain);
|
||||
}
|
||||
|
||||
return isolines;
|
||||
|
||||
function addIsoline(type, vertices, vertexChain) {
|
||||
if (!isolines[type]) isolines[type] = {};
|
||||
|
||||
if (options.polygons) {
|
||||
if (!isolines[type].polygons) isolines[type].polygons = [];
|
||||
isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId]));
|
||||
}
|
||||
|
||||
if (options.fill) {
|
||||
if (!isolines[type].fill) isolines[type].fill = "";
|
||||
isolines[type].fill += getFillPath(vertices, vertexChain);
|
||||
}
|
||||
|
||||
if (options.waterGap) {
|
||||
if (!isolines[type].waterGap) isolines[type].waterGap = "";
|
||||
const isLandVertex = vertexId => vertices.c[vertexId].every(i => cells.h[i] >= 20);
|
||||
isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex);
|
||||
}
|
||||
|
||||
if (options.halo) {
|
||||
if (!isolines[type].halo) isolines[type].halo = "";
|
||||
const isBorderVertex = vertexId => vertices.c[vertexId].some(i => cells.b[i]);
|
||||
isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFillPath(vertices, vertexChain) {
|
||||
const points = vertexChain.map(vertexId => vertices.p[vertexId]);
|
||||
const firstPoint = points.shift();
|
||||
return `M${firstPoint} L${points.join(" ")} Z`;
|
||||
}
|
||||
|
||||
function getBorderPath(vertices, vertexChain, discontinue) {
|
||||
let discontinued = true;
|
||||
let lastOperation = "";
|
||||
const path = vertexChain.map(vertexId => {
|
||||
if (discontinue(vertexId)) {
|
||||
discontinued = true;
|
||||
return "";
|
||||
}
|
||||
|
||||
const operation = discontinued ? "M" : "L";
|
||||
discontinued = false;
|
||||
lastOperation = operation;
|
||||
|
||||
const command = operation === "L" && operation === lastOperation ? "" : operation;
|
||||
return ` ${command}${vertices.p[vertexId]}`;
|
||||
});
|
||||
|
||||
return path.join("").trim();
|
||||
}
|
||||
|
||||
// get single path for an non-continuous array of cells
|
||||
function getVertexPath(cellsArray) {
|
||||
const {cells, vertices} = pack;
|
||||
|
||||
const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true]));
|
||||
const ofSameType = cellId => cellsObj[cellId];
|
||||
const ofDifferentType = cellId => !cellsObj[cellId];
|
||||
|
||||
const checkedCells = new Uint8Array(cells.c.length);
|
||||
const addToChecked = cellId => (checkedCells[cellId] = 1);
|
||||
const isChecked = cellId => checkedCells[cellId] === 1;
|
||||
|
||||
let path = "";
|
||||
|
||||
for (const cellId of cellsArray) {
|
||||
if (isChecked(cellId)) continue;
|
||||
|
||||
const onborderCell = cells.c[cellId].find(ofDifferentType);
|
||||
if (onborderCell === undefined) continue;
|
||||
|
||||
const feature = pack.features[cells.f[onborderCell]];
|
||||
if (feature.type === "lake" && feature.shoreline) {
|
||||
if (feature.shoreline.every(ofSameType)) continue; // inner lake
|
||||
}
|
||||
|
||||
const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
|
||||
if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
|
||||
|
||||
const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
|
||||
if (vertexChain.length < 3) continue;
|
||||
|
||||
path += getFillPath(vertices, vertexChain);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function getPolesOfInaccessibility(graph, getType) {
|
||||
const isolines = getIsolines(graph, getType, {polygons: true});
|
||||
|
||||
const poles = Object.entries(isolines).map(([id, isoline]) => {
|
||||
const multiPolygon = isoline.polygons.sort((a, b) => b.length - a.length);
|
||||
const [x, y] = polylabel(multiPolygon, 20);
|
||||
return [id, [rn(x), rn(y)]];
|
||||
});
|
||||
|
||||
return Object.fromEntries(poles);
|
||||
}
|
||||
|
||||
function connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing}) {
|
||||
const MAX_ITERATIONS = vertices.c.length;
|
||||
const chain = []; // vertices chain to form a path
|
||||
|
||||
let next = startingVertex;
|
||||
for (let i = 0; i === 0 || next !== startingVertex; i++) {
|
||||
const previous = chain.at(-1);
|
||||
const current = next;
|
||||
chain.push(current);
|
||||
|
||||
const neibCells = vertices.c[current];
|
||||
if (addToChecked) neibCells.filter(ofSameType).forEach(addToChecked);
|
||||
|
||||
const [c1, c2, c3] = neibCells.map(ofSameType);
|
||||
const [v1, v2, v3] = vertices.v[current];
|
||||
|
||||
if (v1 !== previous && c1 !== c2) next = v1;
|
||||
else if (v2 !== previous && c2 !== c3) next = v2;
|
||||
else if (v3 !== previous && c1 !== c3) next = v3;
|
||||
|
||||
if (next >= vertices.c.length) {
|
||||
ERROR && console.error("ConnectVertices: next vertex is out of bounds");
|
||||
break;
|
||||
}
|
||||
|
||||
if (next === current) {
|
||||
ERROR && console.error("ConnectVertices: next vertex is not found");
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === MAX_ITERATIONS) {
|
||||
ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (closeRing) chain.push(startingVertex);
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the shortest path between two cells using a cost-based pathfinding algorithm.
|
||||
* @param {number} start - The ID of the starting cell.
|
||||
* @param {(id: number) => boolean} isExit - A function that returns true if the cell is the exit cell.
|
||||
* @param {(current: number, next: number) => number} getCost - A function that returns the path cost from current cell to the next cell. Must return `Infinity` for impassable connections.
|
||||
* @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same.
|
||||
*/
|
||||
function findPath(start, isExit, getCost) {
|
||||
if (isExit(start)) return null;
|
||||
|
||||
const from = [];
|
||||
const cost = [];
|
||||
const queue = new FlatQueue();
|
||||
queue.push(start, 0);
|
||||
|
||||
while (queue.length) {
|
||||
const currentCost = queue.peekValue();
|
||||
const current = queue.pop();
|
||||
|
||||
for (const next of pack.cells.c[current]) {
|
||||
if (isExit(next)) {
|
||||
from[next] = current;
|
||||
return restorePath(next, start, from);
|
||||
}
|
||||
|
||||
const nextCost = getCost(current, next);
|
||||
if (nextCost === Infinity) continue; // impassable cell
|
||||
const totalCost = currentCost + nextCost;
|
||||
|
||||
if (totalCost >= cost[next]) continue; // has cheaper path
|
||||
from[next] = current;
|
||||
cost[next] = totalCost;
|
||||
queue.push(next, totalCost);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// supplementary function for findPath
|
||||
function restorePath(exit, start, from) {
|
||||
const pathCells = [];
|
||||
|
||||
let current = exit;
|
||||
let prev = exit;
|
||||
|
||||
while (current !== start) {
|
||||
pathCells.push(current);
|
||||
prev = from[current];
|
||||
current = prev;
|
||||
}
|
||||
|
||||
pathCells.push(current);
|
||||
|
||||
return pathCells.reverse();
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
"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)), []);
|
||||
};
|
||||
}
|
||||
|
||||
// at
|
||||
if (Array.prototype.at === undefined) {
|
||||
Array.prototype.at = function (index) {
|
||||
if (index < 0) index += this.length;
|
||||
if (index < 0 || index >= this.length) return undefined;
|
||||
return this[index];
|
||||
};
|
||||
}
|
||||
|
||||
// readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10
|
||||
if (ReadableStream.prototype[Symbol.asyncIterator] === undefined) {
|
||||
ReadableStream.prototype[Symbol.asyncIterator] = async function* () {
|
||||
const reader = this.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"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 Gaussian number generator
|
||||
* @param {number} expected - expected value
|
||||
* @param {number} deviation - standard deviation
|
||||
* @param {number} min - minimum value
|
||||
* @param {number} max - maximum value
|
||||
* @param {number} round - round value to n decimals
|
||||
* @return {number} random number
|
||||
*/
|
||||
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("Range 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;
|
||||
}
|
||||
|
||||
function generateSeed() {
|
||||
return String(Math.floor(Math.random() * 1e9));
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
const byId = document.getElementById.bind(document);
|
||||
|
||||
Node.prototype.on = function (name, fn, options) {
|
||||
this.addEventListener(name, fn, options);
|
||||
return this;
|
||||
};
|
||||
|
||||
Node.prototype.off = function (name, fn) {
|
||||
this.removeEventListener(name, fn);
|
||||
return this;
|
||||
};
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
JSON.safeParse = str => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function sanitizeId(string) {
|
||||
if (!string) throw new Error("No string provided");
|
||||
|
||||
let sanitized = string
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9-_]/g, "") // no invalid characters
|
||||
.replace(/\s+/g, "-"); // replace spaces with hyphens
|
||||
|
||||
// remove leading numbers
|
||||
if (sanitized.match(/^\d/)) sanitized = "_" + sanitized;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"use strict";
|
||||
// FMG utils related to units
|
||||
|
||||
// conver temperature from °C to other scales
|
||||
const temperatureConversionMap = {
|
||||
"°C": temp => rn(temp) + "°C",
|
||||
"°F": temp => rn((temp * 9) / 5 + 32) + "°F",
|
||||
K: temp => rn(temp + 273.15) + "K",
|
||||
"°R": temp => rn(((temp + 273.15) * 9) / 5) + "°R",
|
||||
"°De": temp => rn(((100 - temp) * 3) / 2) + "°De",
|
||||
"°N": temp => rn((temp * 33) / 100) + "°N",
|
||||
"°Ré": temp => rn((temp * 4) / 5) + "°Ré",
|
||||
"°Rø": temp => rn((temp * 21) / 40 + 7.5) + "°Rø"
|
||||
};
|
||||
|
||||
function convertTemperature(temp, scale = temperatureScale.value || "°C") {
|
||||
return temperatureConversionMap[scale](temp);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue