[Migration] NPM (#1266)

* chore: add npm + vite for progressive enhancement

* fix: update Dockerfile to copy only the dist folder contents

* fix: update Dockerfile to use multi-stage build for optimized production image

* fix: correct nginx config file copy command in Dockerfile

* chore: add netlify configuration for build and redirects

* fix: add NODE_VERSION to environment in Netlify configuration

* remove wrong dist folder

* Update package.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: split public and src

* migrating all util files from js to ts

* feat: Implement HeightmapGenerator and Voronoi module

- Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.).
- Introduced Voronoi class for creating Voronoi diagrams using Delaunator.
- Updated index.html to include new modules.
- Created index.ts to manage module imports.
- Enhanced arrayUtils and graphUtils with type definitions and improved functionality.
- Added utility functions for generating grids and calculating Voronoi cells.

* chore: add GitHub Actions workflow for deploying to GitHub Pages

* fix: update branch name in GitHub Actions workflow from 'main' to 'master'

* chore: update package.json to specify Node.js engine version and remove unused launch.json

* Initial plan

* Update copilot guidelines to reflect NPM/Vite/TypeScript migration

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/utils/graphUtils.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: Add TIME and ERROR variables to global scope in HeightmapGenerator

* fix: Update base path in vite.config.ts for Netlify deployment

* fix: Update Node.js version in Dockerfile to 24-alpine

---------

Co-authored-by: Marc Emmanuel <marc.emmanuel@tado.com>
Co-authored-by: Marc Emmanuel <marcwissler@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
This commit is contained in:
Azgaar 2026-01-22 12:20:12 +01:00 committed by GitHub
parent 0c26f0831f
commit 9e0eb03618
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
713 changed files with 5182 additions and 2161 deletions

8563
src/index.html Normal file

File diff suppressed because one or more lines are too long

View file

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

2
src/modules/index.ts Normal file
View file

@ -0,0 +1,2 @@
import "./voronoi";
import "./heightmap-generator";

145
src/modules/voronoi.ts Normal file
View file

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

107
src/utils/arrayUtils.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* Get the last element of an array
* @param {Array} array - The array to get the last element from
* @returns The last element of the array
*/
export const last = <T>(array: T[]): T => {
return array[array.length - 1];
}
/**
* Get unique elements from an array
* @param {Array} array - The array to get unique elements from
* @returns An array with unique elements
*/
export const unique = <T>(array: T[]): T[] => {
return [...new Set(array)];
}
/**
* Deep copy an object or array
* @param {Object|Array} obj - The object or array to deep copy
* @returns A deep copy of the object or array
*/
export const deepCopy = <T>(obj: T): T => {
const id = (x: T): T => x;
const dcTArray = (a: T[]): T[] => a.map(id);
const dcObject = (x: object): object => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)]));
const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x);
// don't map keys, probably this is what we would expect
const dcMapCore = (m: Map<any, any>): [any, any][] => [...m.entries()].map(([k, v]) => [k, dcAny(v)]);
const cf: Map<Function, (x: any) => any> = new Map<any, (x: any) => any>([
[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);
}
/**
* Get the appropriate typed array constructor based on the maximum value
* @param {number} maxValue - The maximum value that will be stored in the array
* @returns The typed array constructor
*/
export const getTypedArray = (maxValue: number) => {
console.assert(
Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX,
`Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}`
);
if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT8_MAX) return Uint8Array;
if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array;
if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array;
return Uint32Array;
}
/**
* Create a typed array based on the maximum value and length or from an existing array
* @param {Object} options - The options for creating the typed array
* @param {number} options.maxValue - The maximum value that will be stored in the array
* @param {number} options.length - The length of the typed array to create
* @param {Array} [options.from] - An optional array to create the typed array from
* @returns The created typed array
*/
export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike<number>}): Uint8Array | Uint16Array | Uint32Array => {
const typedArray = getTypedArray(maxValue);
if (!from) return new typedArray(length);
return typedArray.from(from);
}
// typed arrays max values
export const TYPED_ARRAY_MAX_VALUES = {
INT8_MAX: 127,
UINT8_MAX: 255,
UINT16_MAX: 65535,
UINT32_MAX: 4294967295
};
declare global {
interface Window {
last: typeof last;
unique: typeof unique;
deepCopy: typeof deepCopy;
getTypedArray: typeof getTypedArray;
createTypedArray: typeof createTypedArray;
INT8_MAX: number;
UINT8_MAX: number;
UINT16_MAX: number;
UINT32_MAX: number;
}
}

79
src/utils/colorUtils.ts Normal file
View file

@ -0,0 +1,79 @@
import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3";
/**
* Convert RGB or RGBA color to HEX
* @param {string} rgba - The RGB or RGBA color string
* @returns {string} - The HEX color string
*/
export const toHEX = (rgba: string): string => {
if (rgba.charAt(0) === "#") return rgba;
const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
return matches && matches.length === 4
? "#" +
("0" + parseInt(matches[1], 10).toString(16)).slice(-2) +
("0" + parseInt(matches[2], 10).toString(16)).slice(-2) +
("0" + parseInt(matches[3], 10).toString(16)).slice(-2)
: "";
}
/** Predefined set of 12 distinct colors */
export const C_12 = [
"#dababf",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#c6b9c1",
"#bc80bd",
"#ccebc5",
"#ffed6f",
"#8dd3c7",
"#eb8de7"
];
/**
* Get an array of distinct colors
* @param {number} count - The count of colors to generate
* @returns {string[]} - The array of HEX color strings
*/
export const getColors = (count: number): string[] => {
const scaleRainbow = scaleSequential(interpolateRainbow);
const colors = shuffle(
range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex()))
);
return colors.filter((c): c is string => typeof c === "string");
}
/**
* Get a random color in HEX format
* @returns {string} - The HEX color string
*/
export const getRandomColor = (): string => {
const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor;
return colorFromRainbow.formatHex();
}
/**
* Get a mixed color by blending a given color with a random color
* @param {string} color - The base color in HEX format
* @param {number} mix - The mix ratio (0 to 1)
* @param {number} bright - The brightness adjustment
* @returns {string} - The mixed HEX color string
*/
export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): string => {
const c = colorToMix && colorToMix[0] === "#" ? colorToMix : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one
const mixedColor: RGBColor = color(interpolate(c, getRandomColor())(mix)) as RGBColor;
return mixedColor.brighter(bright).formatHex();
}
declare global {
interface Window {
toHEX: typeof toHEX;
getColors: typeof getColors;
getRandomColor: typeof getRandomColor;
getMixedColor: typeof getMixedColor;
C_12: typeof C_12;
}
}

320
src/utils/commonUtils.ts Normal file
View file

@ -0,0 +1,320 @@
import { distanceSquared } from "./functionUtils";
import { rand } from "./probabilityUtils";
import { rn } from "./numberUtils";
import { last } from "./arrayUtils";
/**
* Clip polygon points to graph boundaries
* @param points - Array of points [[x1, y1], [x2, y2], ...]
* @param graphWidth - Width of the graph
* @param graphHeight - Height of the graph
* @param secure - Secure clipping to avoid edge artifacts
* @returns Clipped polygon points
*/
export const clipPoly = (points: [number, number][], graphWidth: number, graphHeight: number, secure: number = 0) => {
if (points.length < 2) return points;
if (points.some(point => point === undefined)) {
window.ERROR && console.error("Undefined point in clipPoly", points);
return points;
}
return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
}
/**
* Get segment of any point on polyline
* @param points - Array of points defining the polyline
* @param point - The point to find the segment for
* @param step - Step size for segment search (default is 10)
* @returns The segment ID (1-indexed)
*/
export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => {
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(distanceSquared(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 = distanceSquared(point, [x, y]);
if (dist >= minDist) continue;
minDist = dist;
minSegment = i + 1;
}
}
return minSegment;
}
/**
* Creates a debounced function that delays invoking func until after ms milliseconds have elapsed
* @param func - The function to debounce
* @param ms - The number of milliseconds to delay
* @returns The debounced function
*/
export const debounce = <T extends (...args: any[]) => any>(func: T, ms: number) => {
let isCooldown = false;
return function (this: any, ...args: Parameters<T>) {
if (isCooldown) return;
func.apply(this, args);
isCooldown = true;
setTimeout(() => (isCooldown = false), ms);
};
}
/**
* Creates a throttled function that only invokes func at most once every ms milliseconds
* @param func - The function to throttle
* @param ms - The number of milliseconds to throttle invocations to
* @returns The throttled function
*/
export const throttle = <T extends (...args: any[]) => any>(func: T, ms: number) => {
let isThrottled = false;
let savedArgs: any[] | null = null;
let savedThis: any = null;
function wrapper(this: any, ...args: Parameters<T>) {
if (isThrottled) {
savedArgs = args;
savedThis = this;
return;
}
func.apply(this, args);
isThrottled = true;
setTimeout(function () {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs as Parameters<T>);
savedArgs = savedThis = null;
}
}, ms);
}
return wrapper;
}
/**
* Parse error to get the readable string in Chrome and Firefox
* @param error - The error object to parse
* @returns Formatted error string with HTML formatting
*/
export const parseError = (error: Error): string => {
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>&nbsp;&nbsp;at ");
return errorParsed;
}
/**
* Convert a URL to base64 encoded data
* @param url - The URL to convert
* @param callback - Callback function that receives the base64 data
*/
export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | null) => void): void => {
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
* @param url - The URL to open
*/
export const openURL = (url: string): void => {
window.open(url, "_blank");
}
/**
* Open project wiki-page
* @param page - The wiki page name/path to open
*/
export const wiki = (page: string): void => {
window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank");
}
/**
* Wrap URL into html a element
* @param URL - The URL for the link
* @param description - The link text/description
* @returns HTML anchor element as a string
*/
export const link = (URL: string, description: string): string => {
return `<a href="${URL}" rel="noopener" target="_blank">${description}</a>`;
}
/**
* Check if Ctrl key (or Cmd on Mac) was pressed during an event
* @param event - The keyboard or mouse event
* @returns True if Ctrl/Cmd was pressed
*/
export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => {
// meta key is cmd key on MacOs
return event.ctrlKey || event.metaKey;
}
/**
* Generate a random date within a specified range
* @param from - Start year (default is 100)
* @param to - End year (default is 1000)
* @returns Formatted date string
*/
export const generateDate = (from: number = 100, to: number = 1000): string => {
return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric"
});
}
/**
* Convert x coordinate to longitude
* @param x - The x coordinate
* @param mapCoordinates - Map coordinates object with lonW and lonT properties
* @param graphWidth - Width of the graph
* @param decimals - Number of decimal places (default is 2)
* @returns Longitude value
*/
export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => {
return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals);
}
/**
* Convert y coordinate to latitude
* @param y - The y coordinate
* @param mapCoordinates - Map coordinates object with latN and latT properties
* @param graphHeight - Height of the graph
* @param decimals - Number of decimal places (default is 2)
* @returns Latitude value
*/
export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => {
return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals);
}
/**
* Convert x,y coordinates to longitude,latitude
* @param x - The x coordinate
* @param y - The y coordinate
* @param mapCoordinates - Map coordinates object
* @param graphWidth - Width of the graph
* @param graphHeight - Height of the graph
* @param decimals - Number of decimal places (default is 2)
* @returns Array with [longitude, latitude]
*/
export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => {
return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)];
}
/**
* Prompt options interface
*/
export interface PromptOptions {
default: number | string;
step?: number;
min?: number;
max?: number;
required?: boolean;
}
/**
* Initialize custom prompt function (prompt does not work in Electron)
* This should be called once when the DOM is ready
*/
export const initializePrompt = (): void => {
const prompt = document.getElementById("prompt");
if (!prompt) return;
const form = prompt.querySelector("#promptForm");
if (!form) return;
const defaultText = "Please provide an input";
const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true};
(window as any).prompt = function (promptText: string = defaultText, options: PromptOptions = defaultOptions, callback?: (value: number | string) => void) {
if (options.default === undefined)
return window.ERROR && console.error("Prompt: options object does not have default value defined");
const input = prompt.querySelector("#promptInput") as HTMLInputElement;
const promptTextElement = prompt.querySelector("#promptText") as HTMLElement;
if (!input || !promptTextElement) return;
promptTextElement.innerHTML = promptText;
const type = typeof options.default === "number" ? "number" : "text";
input.type = type;
if (options.step !== undefined) input.step = options.step.toString();
if (options.min !== undefined) input.min = options.min.toString();
if (options.max !== undefined) input.max = options.max.toString();
input.required = options.required === false ? false : true;
input.placeholder = "type a " + type;
input.value = options.default.toString();
input.style.width = promptText.length > 10 ? "100%" : "auto";
prompt.style.display = "block";
form.addEventListener(
"submit",
(event: 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");
if (cancel) {
cancel.addEventListener("click", () => {
prompt.style.display = "none";
});
}
}
declare global {
interface Window {
ERROR: boolean;
polygonclip: any;
clipPoly: typeof clipPoly;
getSegmentId: typeof getSegmentId;
debounce: typeof debounce;
throttle: typeof throttle;
parseError: typeof parseError;
getBase64: typeof getBase64;
openURL: typeof openURL;
wiki: typeof wiki;
link: typeof link;
isCtrlClick: typeof isCtrlClick;
generateDate: typeof generateDate;
getLongitude: typeof getLongitude;
getLatitude: typeof getLatitude;
getCoordinates: typeof getCoordinates;
}
}

114
src/utils/debugUtils.ts Normal file
View file

@ -0,0 +1,114 @@
import {curveBundle, line, max, min} from "d3";
import { normalize } from "./numberUtils";
import { getGridPolygon } from "./graphUtils";
import { C_12 } from "./colorUtils";
import { round } from "./stringUtils";
/**
* Drawing cell values and polygons for debugging purposes
* @param {any[]} data - Array of data values corresponding to each cell
* @param {any} packedGraph - The packed graph object containing cell positions
*/
export const drawCellsValue = (data: any[], packedGraph: any): void => {
window.debug.selectAll("text").remove();
window.debug
.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0])
.attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1])
.text((d: any) => d);
}
/**
* Drawing polygons colored according to data values for debugging purposes
* @param {number[]} data - Array of numerical values corresponding to each cell
* @param {any} terrs - The SVG group element where the polygons will be drawn
*/
export const drawPolygons = (data: number[], terrs: any, grid: any): void => {
const maximum: number = max(data) as number;
const minimum: number = min(data) as number;
const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme"));
data = data.map(d => 1 - normalize(d, minimum, maximum));
window.debug.selectAll("polygon").remove();
window.debug
.selectAll("polygon")
.data(data)
.enter()
.append("polygon")
.attr("points", (_d: number, i: number) => getGridPolygon(i, grid))
.attr("fill", (d: number) => scheme(d))
.attr("stroke", (d: number) => scheme(d));
}
/**
* Drawing route connections for debugging purposes
* @param {any} pack - The packed graph object containing cell positions and routes
*/
export const drawRouteConnections = (packedGraph: any): void => {
window.debug.select("#connections").remove();
const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8);
const points = packedGraph.cells.p;
const links = packedGraph.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]);
}
}
}
/**
* Drawing a point for debugging purposes
* @param {[number, number]} point - The [x, y] coordinates of the point to draw
* @param {Object} options - Options for drawing the point
* @param {string} options.color - Color of the point
* @param {number} options.radius - Radius of the point
*/
export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => {
window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color);
}
/**
* Drawing a path for debugging purposes
* @param {[number, number][]} points - Array of [x, y] coordinates representing the path
* @param {Object} options - Options for drawing the path
* @param {string} options.color - Color of the path
* @param {number} options.width - Stroke width of the path
*/
export const drawPath = (points: [number, number][], {color = "red", width = 0.5}): void => {
const lineGen = line().curve(curveBundle);
window.debug
.append("path")
.attr("d", round(lineGen(points) as string))
.attr("stroke", color)
.attr("stroke-width", width)
.attr("fill", "none");
}
declare global {
interface Window {
debug: any;
getColorScheme: (name: string) => (t: number) => string;
drawCellsValue: typeof drawCellsValue;
drawPolygons: typeof drawPolygons;
drawRouteConnections: typeof drawRouteConnections;
drawPoint: typeof drawPoint;
drawPath: typeof drawPath;
}
}

View file

@ -0,0 +1,64 @@
/**
* Regroup an array of values by multiple keys and reduce each group
* @param {Array} values - The array of values to regroup
* @param {Function} reduce - The reduce function to apply to each group
* @param {...Function} keys - The key functions to group by
* @returns {Map} - The regrouped and reduced Map
*
* @example
* const data = [
* {category: 'A', type: 'X', value: 10},
* {category: 'A', type: 'Y', value: 20},
* {category: 'B', type: 'X', value: 30},
* {category: 'B', type: 'Y', value: 40},
* ];
* const result = rollups(
* data,
* v => v.reduce((sum, d) => sum + d.value, 0),
* d => d.category,
* d => d.type
* );
* // result is a Map with structure:
* // Map {
* // 'A' => Map { 'X' => 10, 'Y' => 20 },
* // 'B' => Map { 'X' => 30, 'Y' => 40 }
* // }
*/
export const rollups = (values: any[], reduce: (values: any[]) => any, ...keys: ((value: any, index: number, array: any[]) => any)[]) => {
return nest(values, Array.from, reduce, keys);
}
const nest = (values: any[], map: (iterable: Iterable<any>) => any, reduce: (values: any[]) => any, keys: ((value: any, index: number, array: any[]) => any)[]) => {
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);
}
/**
* Calculate squared distance between two points
* @param {[number, number]} p1 - First point [x1, y1]
* @param {[number, number]} p2 - Second point [x2, y2]
* @returns {number} - Squared distance between p1 and p2
*/
export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => {
return (x1 - x2) ** 2 + (y1 - y2) ** 2;
}
declare global {
interface Window {
rollups: typeof rollups;
dist2: typeof distanceSquared;
}
}

465
src/utils/graphUtils.ts Normal file
View file

@ -0,0 +1,465 @@
import Delaunator from "delaunator";
import Alea from "alea";
import { color } from "d3";
import { byId } from "./shorthands";
import { rn } from "./numberUtils";
import { createTypedArray } from "./arrayUtils";
import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi";
/**
* Get boundary points on a regular square grid
* @param {number} width - The width of the area
* @param {number} height - The height of the area
* @param {number} spacing - The spacing between points
* @returns {Array} - An array of boundary points
*/
const getBoundaryPoints = (width: number, height: number, spacing: number): Point[] => {
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: Point[] = [];
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 jittered square grid
* @param {number} width - The width of the area
* @param {number} height - The height of the area
* @param {number} spacing - The spacing between points
* @returns {Array} - An array of jittered grid points
*/
const getJitteredGrid = (width: number, height: number, spacing: number): Point[] => {
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: Point[] = [];
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;
}
/**
* Places points on a jittered grid and calculates spacing and cell counts
* @param {number} graphWidth - The width of the graph
* @param {number} graphHeight - The height of the graph
* @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY
*/
const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, cellsDesired: number, boundary: Point[], points: Point[], cellsX: number, cellsY: number} => {
TIME && console.time("placePoints");
const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0);
const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering
const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid
const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction
const cellCountY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing); // number of cells in y direction
TIME && console.timeEnd("placePoints");
return {spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY};
}
/**
* Checks if the grid needs to be regenerated based on desired parameters
* @param {Object} grid - The current grid object
* @param {number} expectedSeed - The expected seed value
* @param {number} graphWidth - The width of the graph
* @param {number} graphHeight - The height of the graph
* @returns {boolean} - True if the grid should be regenerated, false otherwise
*/
export const shouldRegenerateGrid = (grid: any, expectedSeed: number, graphWidth: number, graphHeight: number) => {
if (expectedSeed && expectedSeed !== grid.seed) return true;
const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0);
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;
}
interface Grid {
spacing: number;
cellsDesired: number;
boundary: Point[];
points: Point[];
cellsX: number;
cellsY: number;
seed: string | number;
cells: Cells;
vertices: Vertices;
}
/**
* Generates a Voronoi grid based on jittered grid points
* @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed
*/
export const generateGrid = (seed: string, graphWidth: number, graphHeight: number): Grid => {
Math.random = Alea(seed); // reset PRNG
const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight);
const {cells, vertices} = calculateVoronoi(points, boundary);
return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed};
}
/**
* Calculates the Voronoi diagram from given points and boundary
* @param {Array} points - The array of points for Voronoi calculation
* @param {Array} boundary - The boundary points to clip the Voronoi cells
* @returns {Object} - An object containing Voronoi cells and vertices
*/
export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Cells, vertices: Vertices} => {
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) as Uint32Array; // array of indexes
const vertices = voronoi.vertices;
TIME && console.timeEnd("calculateVoronoi");
return {cells, vertices};
}
/**
* Returns a cell index on a regular square grid based on x and y coordinates
* @param {number} x - The x coordinate
* @param {number} y - The y coordinate
* @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
* @returns {number} - The index of the cell in the grid
*/
export const findGridCell = (x: number, y: number, grid: any): number => {
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
* @param {number} x - The x coordinate
* @param {number} y - The y coordinate
* @param {number} radius - The search radius
* @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
* @returns {Array} - An array of cell indexes within the specified radius
*/
export const findGridAll = (x: number, y: number, radius: number, grid: any): number[] => {
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: number) {
c[s].forEach(function (e: number) {
if (found.indexOf(e) !== -1) return;
found.push(e);
frontier.push(e);
});
});
r--;
}
}
return found;
}
/**
* Returns the index of the packed cell containing the given x and y coordinates
* @param {number} x - The x coordinate
* @param {number} y - The y coordinate
* @param {number} radius - The search radius (default is Infinity)
* @returns {number|undefined} - The index of the found cell or undefined if not found
*/
export const findClosestCell = (x: number, y: number, radius = Infinity, packedGraph: any): number | undefined => {
if (!packedGraph.cells?.q) return;
const found = packedGraph.cells.q.find(x, y, radius);
return found ? found[2] : undefined;
}
/**
* Returns an array of packed cell indexes within a specified radius from given x and y coordinates
* @param {number} x - The x coordinate
* @param {number} y - The y coordinate
*/
export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => {
const found = packedGraph.cells.q.findAll(x, y, radius);
return found.map((r: any) => r[2]);
}
/**
* Returns the polygon points for a packed cell given its index
* @param {number} i - The index of the packed cell
* @returns {Array} - An array of polygon points for the specified cell
*/
export const getPackPolygon = (cellIndex: number, packedGraph: any) => {
return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]);
}
/**
* Returns the polygon points for a grid cell given its index
* @param {number} i - The index of the grid cell
* @returns {Array} - An array of polygon points for the specified grid cell
*/
export const getGridPolygon = (i: number, grid: any) => {
return grid.cells.v[i].map((v: number) => grid.vertices.p[v]);
}
/**
* mbostock's poissonDiscSampler implementation
* Generates points using Poisson-disc sampling within a specified rectangle
* @param {number} x0 - The minimum x coordinate of the rectangle
* @param {number} y0 - The minimum y coordinate of the rectangle
* @param {number} x1 - The maximum x coordinate of the rectangle
* @param {number} y1 - The maximum y coordinate of the rectangle
* @param {number} r - The minimum distance between points
* @param {number} k - The number of attempts before rejection (default is 3)
* @yields {Array} - An array containing the x and y coordinates of a generated point
*/
export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: number, r: number, 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: [number, number][] = [];
function far(x: number, y: number) {
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: number, y: number) {
const point: [number, number] = [x, y];
queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point));
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 (r !== undefined && i < queue.length) queue[i] = r;
}
}
/**
* Checks if a packed cell is land based on its height
* @param {number} i - The index of the packed cell
* @returns {boolean} - True if the cell is land, false otherwise
*/
export const isLand = (i: number, packedGraph: any) => {
return packedGraph.cells.h[i] >= 20;
}
/**
* Checks if a packed cell is water based on its height
* @param {number} i - The index of the packed cell
* @returns {boolean} - True if the cell is water, false otherwise
*/
export const isWater = (i: number, packedGraph: any) => {
return packedGraph.cells.h[i] < 20;
}
export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => {
const radiusSearchInit = (t: any, radius: number) => {
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;
};
const radiusSearchVisit = (t: any, d2: number) => {
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));
}
};
class Quad {
node: any;
x0: number;
y0: number;
x1: number;
y1: number;
constructor(node: any, x0: number, y0: number, x1: number, y1: number) {
this.node = node;
this.x0 = x0;
this.y0 = y0;
this.x1 = x1;
this.y1 = y1;
}
}
const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._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 cant 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: number = (t.x1 + t.x2) / 2,
ym: number = (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 isnt necessary!)
else {
var dx = x - +quadtree._x.call(null, t.node.data),
dy = y - +quadtree._y.call(null, t.node.data),
d2 = dx * dx + dy * dy;
radiusSearchVisit(t, d2);
}
}
return t.result;
}
// draw raster heightmap preview (not used in main generation)
/**
* Draws a raster heightmap preview based on given heights and rendering options
* @param {Object} options - The options for drawing the heightmap
* @param {Array} options.heights - The array of height values
* @param {number} options.width - The width of the heightmap
* @param {number} options.height - The height of the heightmap
* @param {Function} options.scheme - The color scheme function for rendering heights
* @param {boolean} options.renderOcean - Whether to render ocean heights
* @returns {string} - A data URL representing the drawn heightmap image
*/
export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heights: number[], width: number, height: number, scheme: (value: number) => string, renderOcean: boolean}) => {
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: number) => (height < 20 ? (renderOcean ? height : 0) : height);
for (let i = 0; i < heights.length; i++) {
const colorScheme = scheme(1 - getHeight(heights[i]) / 100);
const {r, g, b} = color(colorScheme)!.rgb();
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");
}
declare global {
var TIME: boolean;
interface Window {
shouldRegenerateGrid: typeof shouldRegenerateGrid;
generateGrid: typeof generateGrid;
findCell: typeof findClosestCell;
findGridCell: typeof findGridCell;
findGridAll: typeof findGridAll;
calculateVoronoi: typeof calculateVoronoi;
findAll: typeof findAllCellsInRadius;
getPackPolygon: typeof getPackPolygon;
getGridPolygon: typeof getGridPolygon;
poissonDiscSampler: typeof poissonDiscSampler;
isLand: typeof isLand;
isWater: typeof isWater;
findAllInQuadtree: typeof findAllInQuadtree;
drawHeights: typeof drawHeights;
}
}

236
src/utils/index.ts Normal file
View file

@ -0,0 +1,236 @@
import "./polyfills";
import { rn, lim, minmax, normalize, lerp } from "./numberUtils";
window.rn = rn;
window.lim = lim;
window.minmax = minmax;
window.normalize = normalize;
window.lerp = lerp as typeof window.lerp;
import { isVowel, trimVowels, getAdjective, nth, abbreviate, list } from "./languageUtils";
window.vowel = isVowel;
window.trimVowels = trimVowels;
window.getAdjective = getAdjective;
window.nth = nth;
window.abbreviate = abbreviate;
window.list = list;
import { last, unique, deepCopy, getTypedArray, createTypedArray, TYPED_ARRAY_MAX_VALUES } from "./arrayUtils";
window.last = last;
window.unique = unique;
window.deepCopy = deepCopy;
window.getTypedArray = getTypedArray;
window.createTypedArray = createTypedArray;
window.INT8_MAX = TYPED_ARRAY_MAX_VALUES.INT8_MAX;
window.UINT8_MAX = TYPED_ARRAY_MAX_VALUES.UINT8_MAX;
window.UINT16_MAX = TYPED_ARRAY_MAX_VALUES.UINT16_MAX;
window.UINT32_MAX = TYPED_ARRAY_MAX_VALUES.UINT32_MAX;
import { rand, P, each, gauss, Pint, biased, generateSeed, getNumberInRange, ra, rw } from "./probabilityUtils";
window.rand = rand;
window.P = P;
window.each = each;
window.gauss = gauss;
window.Pint = Pint;
window.ra = ra;
window.rw = rw;
window.biased = biased;
window.getNumberInRange = getNumberInRange;
window.generateSeed = generateSeed;
import { convertTemperature, si, getIntegerFromSI } from "./unitUtils";
window.convertTemperature = (temp:number, scale: any = (window as any).temperatureScale.value || "°C") => convertTemperature(temp, scale);
window.si = si;
window.getInteger = getIntegerFromSI;
import { toHEX, getColors, getRandomColor, getMixedColor, C_12 } from "./colorUtils";
window.toHEX = toHEX;
window.getColors = getColors;
window.getRandomColor = getRandomColor;
window.getMixedColor = getMixedColor;
window.C_12 = C_12;
import { getComposedPath, getNextId } from "./nodeUtils";
window.getComposedPath = getComposedPath;
window.getNextId = getNextId;
import { rollups, distanceSquared } from "./functionUtils";
window.rollups = rollups;
window.dist2 = distanceSquared;
import { getIsolines, getPolesOfInaccessibility, connectVertices, findPath, getVertexPath } from "./pathUtils";
window.getIsolines = getIsolines;
window.getPolesOfInaccessibility = getPolesOfInaccessibility;
window.connectVertices = connectVertices;
window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack);
window.getVertexPath = (cellsArray) => getVertexPath(cellsArray, (window as any).pack);
import { round, capitalize, splitInTwo, parseTransform, isValidJSON, safeParseJSON, sanitizeId } from "./stringUtils";
window.round = round;
window.capitalize = capitalize;
window.splitInTwo = splitInTwo;
window.parseTransform = parseTransform;
window.sanitizeId = sanitizeId;
JSON.isValid = isValidJSON;
JSON.safeParse = safeParseJSON;
import { byId } from "./shorthands";
window.byId = byId;
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;
};
declare global {
interface JSON {
isValid: (str: string) => boolean;
safeParse: (str: string) => any;
}
interface Node {
on: (name: string, fn: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => Node;
off: (name: string, fn: EventListenerOrEventListenerObject) => Node;
}
}
import { shouldRegenerateGrid, generateGrid, findGridAll, findGridCell, findClosestCell, calculateVoronoi, findAllCellsInRadius, getPackPolygon, getGridPolygon, poissonDiscSampler, isLand, isWater, findAllInQuadtree, drawHeights } from "./graphUtils";
window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => shouldRegenerateGrid(grid, expectedSeed, (window as any).graphWidth, (window as any).graphHeight);
window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight);
window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid);
window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid);
window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack);
window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack);
window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack);
window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid);
window.calculateVoronoi = calculateVoronoi;
window.poissonDiscSampler = poissonDiscSampler;
window.findAllInQuadtree = findAllInQuadtree;
window.drawHeights = drawHeights;
window.isLand = (i: number) => isLand(i, (window as any).pack);
window.isWater = (i: number) => isWater(i, (window as any).pack);
import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils";
window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, (window as any).graphWidth, (window as any).graphHeight, secure);
window.getSegmentId = getSegmentId;
window.debounce = debounce;
window.throttle = throttle;
window.parseError = parseError;
window.getBase64 = getBase64;
window.openURL = openURL;
window.wiki = wiki;
window.link = link;
window.isCtrlClick = isCtrlClick;
window.generateDate = generateDate;
window.getLongitude = (x: number, decimals?: number) => getLongitude(x, (window as any).mapCoordinates, (window as any).graphWidth, decimals);
window.getLatitude = (y: number, decimals?: number) => getLatitude(y, (window as any).mapCoordinates, (window as any).graphHeight, decimals);
window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, (window as any).mapCoordinates, (window as any).graphWidth, (window as any).graphHeight, decimals);
// Initialize prompt when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePrompt);
} else {
initializePrompt();
}
import { drawCellsValue, drawPolygons, drawRouteConnections, drawPoint, drawPath } from "./debugUtils";
window.drawCellsValue = (data:any[]) => drawCellsValue(data, (window as any).pack);
window.drawPolygons = (data: any[]) => drawPolygons(data, (window as any).terrs, (window as any).grid);
window.drawRouteConnections = () => drawRouteConnections((window as any).packedGraph);
window.drawPoint = drawPoint;
window.drawPath = drawPath;
export {
rn,
lim,
minmax,
normalize,
lerp,
isVowel,
trimVowels,
getAdjective,
nth,
abbreviate,
list,
last,
unique,
deepCopy,
getTypedArray,
createTypedArray,
TYPED_ARRAY_MAX_VALUES,
rand,
P,
each,
gauss,
Pint,
biased,
generateSeed,
getNumberInRange,
ra,
rw,
convertTemperature,
si,
getIntegerFromSI,
toHEX,
getColors,
getRandomColor,
getMixedColor,
C_12,
getComposedPath,
getNextId,
rollups,
distanceSquared,
getIsolines,
getPolesOfInaccessibility,
connectVertices,
findPath,
getVertexPath,
round,
capitalize,
splitInTwo,
parseTransform,
isValidJSON,
safeParseJSON,
sanitizeId,
byId,
shouldRegenerateGrid,
generateGrid,
findGridAll,
findGridCell,
findClosestCell,
calculateVoronoi,
findAllCellsInRadius,
getPackPolygon,
getGridPolygon,
poissonDiscSampler,
isLand,
isWater,
findAllInQuadtree,
drawHeights,
clipPoly,
getSegmentId,
debounce,
throttle,
parseError,
getBase64,
openURL,
wiki,
link,
isCtrlClick,
generateDate,
getLongitude,
getLatitude,
getCoordinates,
initializePrompt,
drawCellsValue,
drawPolygons,
drawRouteConnections,
drawPoint,
drawPath
}

217
src/utils/languageUtils.ts Normal file
View file

@ -0,0 +1,217 @@
import { last } from "./arrayUtils";
import { P } from "./probabilityUtils";
/**
* Check if character is a vowel
* @param c - The character to check.
* @returns True if the character is a vowel, false otherwise.
*/
export const isVowel = (c: string): boolean => {
const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`;
return VOWELS.includes(c);
}
/**
* Remove trailing vowels from a string until it reaches a minimum length.
* @param string - The input string.
* @param minLength - The minimum length of the string after trimming (default is 3).
* @returns The trimmed string.
*/
export const trimVowels = (string: string, minLength: number = 3) => {
while (string.length > minLength && isVowel(last(Array.from(string)))) {
string = string.slice(0, -1);
}
return string;
}
/**
* Get adjective form of a noun based on predefined rules.
* @param noun - The noun to be converted to an adjective.
* @returns The adjective form of the noun.
*/
export const getAdjective = (nounToBeAdjective: string) => {
const adjectivizationRules = [
{
name: "guo",
probability: 1,
condition: new RegExp(" Guo$"),
action: (noun: string) => noun.slice(0, -4)
},
{
name: "orszag",
probability: 1,
condition: new RegExp("orszag$"),
action: (noun: string) => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6))
},
{
name: "stan",
probability: 1,
condition: new RegExp("stan$"),
action: (noun: string) => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4)))
},
{
name: "land",
probability: 1,
condition: new RegExp("land$"),
action: (noun: string) => {
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: string) => noun.replace(/que$/, "can")
},
{
name: "a",
probability: 1,
condition: new RegExp("a$"),
action: (noun: string) => noun + "n"
},
{
name: "o",
probability: 1,
condition: new RegExp("o$"),
action: (noun: string) => noun.replace(/o$/, "an")
},
{
name: "u",
probability: 1,
condition: new RegExp("u$"),
action: (noun: string) => noun + "an"
},
{
name: "i",
probability: 1,
condition: new RegExp("i$"),
action: (noun: string) => noun + "an"
},
{
name: "e",
probability: 1,
condition: new RegExp("e$"),
action: (noun: string) => noun + "an"
},
{
name: "ay",
probability: 1,
condition: new RegExp("ay$"),
action: (noun: string) => noun + "an"
},
{
name: "os",
probability: 1,
condition: new RegExp("os$"),
action: (noun: string) => {
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: string) => {
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: string) => noun + "ese"
},
{
name: "n",
probability: 0.8,
condition: new RegExp("n$"),
action: (noun: string) => noun + "ese"
},
{
name: "ad",
probability: 0.8,
condition: new RegExp("ad$"),
action: (noun: string) => noun + "ian"
},
{
name: "an",
probability: 0.8,
condition: new RegExp("an$"),
action: (noun: string) => noun + "ian"
},
{
name: "ish",
probability: 0.25,
condition: new RegExp("^[a-zA-Z]{6}$"),
action: (noun: string) => trimVowels(noun.slice(0, -1)) + "ish"
},
{
name: "an",
probability: 0.5,
condition: new RegExp("^[a-zA-Z]{0,7}$"),
action: (noun: string) => trimVowels(noun) + "an"
}
];
for (const rule of adjectivizationRules) {
if (P(rule.probability) && rule.condition.test(nounToBeAdjective)) {
return rule.action(nounToBeAdjective);
}
}
return nounToBeAdjective; // no rule applied, return noun as is
}
/**
* Get the ordinal suffix for a given number.
* @param n - The number.
* @returns The number with its ordinal suffix.
*/
export const nth = (n: number) => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th");
/**
* Generate an abbreviation for a given name, avoiding restricted codes.
* @param name - The name to be abbreviated.
* @param restricted - An array of restricted abbreviations to avoid (default is an empty array).
* @returns The generated abbreviation.
*/
export const abbreviate = (name: string, restricted: string[] = []) => {
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;
}
/**
* Format a list of strings into a human-readable list.
* @param array - The array of strings to be formatted.
* @returns The formatted list as a string.
*/
export const list = (array: string[]) => {
if (!Intl.ListFormat) return array.join(", ");
const conjunction = new Intl.ListFormat(document.documentElement.lang || "en", {style: "long", type: "conjunction"});
return conjunction.format(array);
}
declare global {
interface Window {
vowel: typeof isVowel;
trimVowels: typeof trimVowels;
getAdjective: typeof getAdjective;
nth: typeof nth;
abbreviate: typeof abbreviate;
list: typeof list;
}
}

31
src/utils/nodeUtils.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Get the composed path of a node (including shadow DOM and window)
* @param {Node | Window} node - The starting node or window
* @returns {Array<Node>} - The composed path as an array
*/
export const getComposedPath = function(node: any): Array<Node | Window> {
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];
}
/**
* Generate a unique ID for a given core string
* @param {string} core - The core string for the ID
* @param {number} [i=1] - The starting index
* @returns {string} - The unique ID
*/
export const getNextId = function(core: string, i: number = 1): string {
while (document.getElementById(core + i)) i++;
return core + i;
}
declare global {
interface Window {
getComposedPath: typeof getComposedPath;
getNextId: typeof getNextId;
}
}

62
src/utils/numberUtils.ts Normal file
View file

@ -0,0 +1,62 @@
/**
* Rounds a number to a specified number of decimal places.
* @param v - The number to be rounded.
* @param d - The number of decimal places to round to (default is 0).
* @returns The rounded number.
*/
export const rn = (v: number, d: number = 0) => {
const m = Math.pow(10, d);
return Math.round(v * m) / m;
}
/**
* Clamps a number between a minimum and maximum value.
* @param value - The number to be clamped.
* @param min - The minimum value.
* @param max - The maximum value.
* @returns The clamped number.
*/
export const minmax = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
}
/**
* Clamps a number between 0 and 100.
* @param v - The number to be clamped.
* @returns The clamped number.
*/
export const lim = (v: number) => {
return minmax(v, 0, 100);
}
/**
* Normalizes a number within a specified range to a value between 0 and 1.
* @param val - The number to be normalized.
* @param min - The minimum value of the range.
* @param max - The maximum value of the range.
* @returns The normalized number.
*/
export const normalize = (val: number, min: number, max: number) => {
return minmax((val - min) / (max - min), 0, 1);
}
/**
* Performs linear interpolation between two values.
* @param a - The starting value.
* @param b - The ending value.
* @param t - The interpolation factor (between 0 and 1).
* @returns The interpolated value.
*/
export const lerp = (a: number, b: number, t: number) => {
return a + (b - a) * t;
}
declare global {
interface Window {
rn: typeof rn;
minmax: typeof minmax;
lim: typeof lim;
normalize: typeof normalize;
lerp: typeof lerp;
}
}

300
src/utils/pathUtils.ts Normal file
View file

@ -0,0 +1,300 @@
import polylabel from "polylabel";
import { rn } from "./numberUtils";
/**
* Generates SVG path data for filling a shape defined by a chain of vertices.
* @param {object} vertices - The vertices object containing positions.
* @param {number[]} vertexChain - An array of vertex IDs defining the shape.
* @returns {string} SVG path data for the filled shape.
*/
const getFillPath = (vertices: any, vertexChain: number[]) => {
const points = vertexChain.map(vertexId => vertices.p[vertexId]);
const firstPoint = points.shift();
return `M${firstPoint} L${points.join(" ")} Z`;
}
/**
* Generates SVG path data for borders based on a chain of vertices and a discontinuation condition.
* @param {object} vertices - The vertices object containing positions.
* @param {number[]} vertexChain - An array of vertex IDs defining the border.
* @param {(vertexId: number) => boolean} discontinue - A function that determines if the path should discontinue at a vertex.
* @returns {string} SVG path data for the border.
*/
const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (vertexId: number) => boolean) => {
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();
}
/**
* Restores the path from exit to start using the 'from' mapping.
* @param {number} exit - The ID of the exit cell.
* @param {number} start - The ID of the starting cell.
* @param {number[]} from - An array mapping each cell ID to the cell ID it came from.
* @returns {number[]} An array of cell IDs representing the path from start to exit.
*/
const restorePath = (exit: number, start: number, from: number[]) => {
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();
}
/**
* Returns isolines (borders) for different types of cells in the graph.
* @param {object} graph - The graph object containing cells and vertices.
* @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID.
* @param {object} [options] - Options to specify which isoline formats to generate.
* @param {boolean} [options.polygons=false] - Whether to generate polygons for each type.
* @param {boolean} [options.fill=false] - Whether to generate fill paths for each type.
* @param {boolean} [options.halo=false] - Whether to generate halo paths for each type.
* @param {boolean} [options.waterGap=false] - Whether to generate water gap paths for each type.
* @returns {object} An object containing isolines for each type based on the specified options.
*/
export const getIsolines = (graph: any, getType: (cellId: number) => any, options: {polygons?: boolean, fill?: boolean, halo?: boolean, waterGap?: boolean} = {polygons: false, fill: false, halo: false, waterGap: false}): any => {
const {cells, vertices} = graph;
const isolines: any = {};
const checkedCells = new Uint8Array(cells.i.length);
const addToChecked = (cellId: number) => (checkedCells[cellId] = 1);
const isChecked = (cellId: number) => checkedCells[cellId] === 1;
for (const cellId of cells.i) {
if (isChecked(cellId) || !getType(cellId)) continue;
addToChecked(cellId);
const type = getType(cellId);
const ofSameType = (cellId: number) => getType(cellId) === type;
const ofDifferentType = (cellId: number) => 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: number) => 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;
addIsolineTo(type, vertices, vertexChain, isolines, options);
}
return isolines;
function addIsolineTo(type: any, vertices: any, vertexChain: number[], isolines: any, options: any) {
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: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20);
isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex);
}
if (options.halo) {
if (!isolines[type].halo) isolines[type].halo = "";
const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]);
isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex);
}
}
}
/**
* Generates SVG path data for the border of a shape defined by a chain of vertices.
* @param {number[]} cellsArray - An array of cell IDs defining the shape.
* @param {object} packedGraph - The packed graph object containing cells and vertices.
* @returns {string} SVG path data for the border of the shape.
*/
export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => {
const {cells, vertices} = packedGraph;
const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true]));
const ofSameType = (cellId: number) => cellsObj[cellId];
const ofDifferentType = (cellId: number) => !cellsObj[cellId];
const checkedCells = new Uint8Array(cells.c.length);
const addToChecked = (cellId: number) => (checkedCells[cellId] = 1);
const isChecked = (cellId: number) => 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 = packedGraph.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: number) => 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;
}
/**
* Finds the poles of inaccessibility for each type of cell in the graph.
* @param {object} graph - The graph object containing cells and vertices.
* @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID.
* @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y].
*/
export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => {
const isolines = getIsolines(graph, getType, {polygons: true});
const poles = Object.entries(isolines).map(([id, isoline]) => {
const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length);
const [x, y] = polylabel(multiPolygon, 20);
return [id, [rn(x), rn(y)]];
});
return Object.fromEntries(poles);
}
/**
* Connects vertices to form a closed path based on cell type.
* @param {object} options - Options for connecting vertices.
* @param {object} options.vertices - The vertices object containing connections.
* @param {number} options.startingVertex - The ID of the starting vertex.
* @param {(cellId: number) => boolean} options.ofSameType - A function that checks if a cell is of the same type.
* @param {(cellId: number) => void} [options.addToChecked] - A function to mark cells as checked.
* @param {boolean} [options.closeRing=false] - Whether to close the path into a ring.
* @returns {number[]} An array of vertex IDs forming the connected path.
*/
export const connectVertices = ({vertices, startingVertex, ofSameType, addToChecked, closeRing}: {vertices: any, startingVertex: number, ofSameType: (cellId: number) => boolean, addToChecked?: (cellId: number) => void, closeRing?: boolean}) => {
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) {
window.ERROR && console.error("ConnectVertices: next vertex is out of bounds");
break;
}
if (next === current) {
window.ERROR && console.error("ConnectVertices: next vertex is not found");
break;
}
if (i === MAX_ITERATIONS) {
window.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.
* @param {object} packedGraph - The packed graph object containing cells and their 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.
*/
export const findPath = (start: number, isExit: (id: number) => boolean, getCost: (current: number, next: number) => number, packedGraph: any = {}): number[] | null => {
if (isExit(start)) return null;
const from = [];
const cost = [];
const queue = new window.FlatQueue();
queue.push(start, 0);
while (queue.length) {
const currentCost = queue.peekValue();
const current = queue.pop();
for (const next of packedGraph.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;
}
declare global {
interface Window {
ERROR: boolean;
FlatQueue: any;
getIsolines: typeof getIsolines;
getPolesOfInaccessibility: typeof getPolesOfInaccessibility;
connectVertices: typeof connectVertices;
findPath: typeof findPath;
getVertexPath: typeof getVertexPath;
}
}

54
src/utils/polyfills.ts Normal file
View file

@ -0,0 +1,54 @@
// replaceAll
if (String.prototype.replaceAll === undefined) {
String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string {
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any);
return this.replace(new RegExp(str, "g"), newStr as any);
};
}
// flat
if (Array.prototype.flat === undefined) {
Array.prototype.flat = function <T>(this: T[], depth?: number): any[] {
return (this as Array<unknown>).reduce((acc: any[], val: unknown) => (Array.isArray(val) ? acc.concat((val as any).flat(depth)) : acc.concat(val)), []);
};
}
// at
if (Array.prototype.at === undefined) {
Array.prototype.at = function <T>(this: T[], index: number): T | undefined {
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 as any)[Symbol.asyncIterator] === undefined) {
(ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* <R>(this: ReadableStream<R>): AsyncGenerator<R, void, unknown> {
const reader = this.getReader();
try {
while (true) {
const {done, value} = await reader.read();
if (done) return;
yield value;
}
} finally {
reader.releaseLock();
}
};
}
declare global {
interface String {
replaceAll(searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string;
}
interface Array<T> {
flat(depth?: number): T[];
at(index: number): T | undefined;
}
interface ReadableStream<R> {
[Symbol.asyncIterator](): AsyncIterableIterator<R>;
}
}

View file

@ -0,0 +1,147 @@
import { minmax, rn } from "./numberUtils";
import { randomNormal } from "d3";
/**
* Creates a random number between min and max (inclusive).
* @param {number} min - minimum value
* @param {number} max - maximum value
* @return {number} random integer between min and max
*/
export const rand = (min: number, max?: number): number => {
if (min === undefined && max === undefined) return Math.random();
if (max === undefined) {
max = min;
min = 0;
}
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Returns a boolean based on the given probability.
* @param {number} probability - probability between 0 and 1
* @return {boolean} true with the given probability
*/
export const P = (probability: number): boolean => {
if (probability >= 1) return true;
if (probability <= 0) return false;
return Math.random() < probability;
}
/**
* Returns true every n times.
* @param {number} n - the interval
* @return {function} function that takes the current index and returns true every n times
*/
export const each = (n: number) => {
return (i: number) => 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
*/
export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => {
return rn(minmax(randomNormal(expected, deviation)(), min, max), round);
}
/**
* Returns the integer part of a float plus one with the probability of the decimal part.
* @param {number} float - the float number
* @return {number} the resulting integer
*/
export const Pint = (float: number): number => {
return ~~float + +P(float % 1);
}
/**
* Returns a random element from an array.
* @param {Array} array - the array to pick from
* @return {any} a random element from the array
*/
export const ra = (array: any[]): any => {
return array[Math.floor(Math.random() * array.length)];
}
/**
* Returns a random key from an object where values are weights.
* @param {Object} object - object with keys and their weights
* @return {string} a random key based on weights
*
* @example
* const obj = { a: 1, b: 3, c: 6 };
* const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance
*/
export const rw = (object: {[key: string]: number}): string => {
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)];
}
/**
* Returns a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min).
* @param {number} min - minimum value
* @param {number} max - maximum value
* @param {number} ex - exponent for bias
* @return {number} biased random integer
*/
export const biased = (min: number, max: number, ex: number): number => {
return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
}
const ERROR = false;
/**
* Get number from string in format "1-3" or "2" or "0.5"
* @param {string} r - range string
* @return {number} parsed number
*/
export const getNumberInRange = (r: string): number => {
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(parseFloat(range[0]) * sign, +parseFloat(range[1]));
if (isNaN(count) || count < 0) {
ERROR && console.error("Cannot parse number. Check the format", r);
return 0;
}
return count;
}
/**
* Generate a random seed string
* @return {string} random seed
*/
export const generateSeed = (): string => {
return String(Math.floor(Math.random() * 1e9));
}
declare global {
interface Window {
rand: typeof rand;
P: typeof P;
each: typeof each;
gauss: typeof gauss;
Pint: typeof Pint;
ra: typeof ra;
rw: typeof rw;
biased: typeof biased;
getNumberInRange: typeof getNumberInRange;
generateSeed: typeof generateSeed;
}
}

7
src/utils/shorthands.ts Normal file
View file

@ -0,0 +1,7 @@
export const byId = document.getElementById.bind(document);
declare global {
interface Window {
byId: typeof byId;
}
}

125
src/utils/stringUtils.ts Normal file
View file

@ -0,0 +1,125 @@
import { rn } from "./numberUtils";
/**
* Round all numbers in a string to d decimal places
* @param {string} inputString - The input string
* @param {number} decimals - Number of decimal places (default is 1)
* @returns {string} - The string with rounded numbers
*/
export const round = (inputString: string, decimals: number = 1) => {
return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => {
return rn(parseFloat(n), decimals).toString();
});
}
/**
* Capitalize the first letter of a string
* @param {string} inputString - The input string
* @returns {string} - The capitalized string
*/
export const capitalize = (inputString: string) => {
return inputString.charAt(0).toUpperCase() + inputString.slice(1);
}
/**
* Split a string into two parts, trying to balance their lengths
* @param {string} inputString - The input string
* @returns {[string, string]} - An array with two parts of the string
*/
export const splitInTwo = (inputString: string): string[] => {
const half = inputString.length / 2;
const ar = inputString.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];
}
/**
* Parse an SVG transform string into an array of numbers
* @param {string} string - The SVG transform string
* @returns {[number, number, number, number, number, number]} - The parsed transform as an array
*
* @example
* parseTransform("matrix(1, 0, 0, 1, 100, 200)") // returns [1, 0, 0, 1, 100, 200]
* parseTransform("translate(50, 75)") // returns [50, 75, 0, 0, 0, 1]
*/
export const parseTransform = (string: 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 a string is valid JSON
* @param {string} str - The string to check
* @returns {boolean} - True if the string is valid JSON, false otherwise
*/
export const isValidJSON = (str: string): boolean => {
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
};
/**
* Safely parse a JSON string
* @param {string} str - The JSON string to parse
* @returns {any|null} - The parsed object, or null if parsing failed
*/
export const safeParseJSON = (str: string) => {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
};
/**
* Sanitize a string to be used as an ID
* @param {string} string - The input string
* @returns {string} - The sanitized ID string
*/
export const sanitizeId = (inputString: string) => {
if (!inputString) throw new Error("No string provided");
let sanitized = inputString
.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;
}
declare global {
interface Window {
round: typeof round;
capitalize: typeof capitalize;
splitInTwo: typeof splitInTwo;
parseTransform: typeof parseTransform;
sanitizeId: typeof sanitizeId;
}
}

57
src/utils/unitUtils.ts Normal file
View file

@ -0,0 +1,57 @@
import { rn } from "./numberUtils";
type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" | "°Rø";
/**
* Convert temperature from Celsius to other scales
* @param {number} temperatureInCelsius - Temperature in Celsius
* @param {string} targetScale - Target temperature scale
* @returns {string} - Converted temperature with unit
*/
export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => {
const temperatureConversionMap: {[key: string]: (temp: number) => string} = {
"°C": (temp: number) => rn(temp) + "°C",
"°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F",
K: (temp: number) => rn(temp + 273.15) + "K",
"°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R",
"°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De",
"°N": (temp: number) => rn((temp * 33) / 100) + "°N",
"°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré",
"°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø"
};
return temperatureConversionMap[targetScale](temperatureInCelsius);
}
/**
* Convert number to short string with SI postfix
* @param {number} n - The number to convert
* @returns {string} - The converted string
*/
export const si = (n: number): string => {
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).toString();
}
/**
* Convert string with SI postfix to integer
* @param {string} value - The string to convert
* @returns {number} - The converted integer
*/
export const getIntegerFromSI = (value: string): number => {
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);
}
declare global {
interface Window {
convertTemperature: typeof convertTemperature;
si: typeof si;
getInteger: typeof getIntegerFromSI;
}
}