Merge branch 'master' into refactor/migrate-names-generator

This commit is contained in:
Marc Emmanuel 2026-01-27 09:36:22 +01:00 committed by GitHub
commit 6bd659942f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2094 additions and 755 deletions

22
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Code quality
on:
push:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: latest
- name: Run Biome
run: biome ci .

17
.github/workflows/unit-tests.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: Unit Tests
on:
pull_request:
branches: [ master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Run Unit tests
run: npm run test

58
biome.json Normal file
View file

@ -0,0 +1,58 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["src/**/*.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useTemplate": {
"level": "warn",
"fix": "safe"
},
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noGlobalIsNan": {
"level": "error",
"fix": "safe"
}
},
"correctness": {
"noUnusedVariables": {
"level": "error",
"fix": "safe"
},
"useParseIntRadix": {
"fix": "safe",
"level": "error"
}
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

165
package-lock.json generated
View file

@ -15,6 +15,7 @@
"polylabel": "^2.0.1" "polylabel": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.12",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/delaunator": "^5.0.3", "@types/delaunator": "^5.0.3",
@ -31,6 +32,169 @@
"node": ">=24.0.0" "node": ">=24.0.0"
} }
}, },
"node_modules/@biomejs/biome": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz",
"integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.12",
"@biomejs/cli-darwin-x64": "2.3.12",
"@biomejs/cli-linux-arm64": "2.3.12",
"@biomejs/cli-linux-arm64-musl": "2.3.12",
"@biomejs/cli-linux-x64": "2.3.12",
"@biomejs/cli-linux-x64-musl": "2.3.12",
"@biomejs/cli-win32-arm64": "2.3.12",
"@biomejs/cli-win32-x64": "2.3.12"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz",
"integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz",
"integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz",
"integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz",
"integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz",
"integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz",
"integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz",
"integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz",
"integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@ -2025,7 +2189,6 @@
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"playwright-core": "1.57.0" "playwright-core": "1.57.0"
}, },

View file

@ -19,9 +19,12 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:browser": "vitest --config=vitest.browser.config.ts", "test:browser": "vitest --config=vitest.browser.config.ts",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"lint": "biome check --write",
"format": "biome format --write"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.12",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/delaunator": "^5.0.3", "@types/delaunator": "^5.0.3",

View file

@ -187,7 +187,7 @@ const onZoom = debounce(function () {
}, 50); }, 50);
const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoom); const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoom);
let mapCoordinates = {}; // map coordinates on globe var mapCoordinates = {}; // map coordinates on globe
let populationRate = +byId("populationRateInput").value; let populationRate = +byId("populationRateInput").value;
let distanceScale = +byId("distanceScaleInput").value; let distanceScale = +byId("distanceScaleInput").value;
let urbanization = +byId("urbanizationInput").value; let urbanization = +byId("urbanizationInput").value;

View file

@ -1,4 +1,4 @@
import { range, mean } from "d3"; import { mean, range } from "d3";
import { rn } from "../utils"; import { rn } from "../utils";
declare global { declare global {
@ -22,7 +22,7 @@ class BiomesModule {
"Taiga", "Taiga",
"Tundra", "Tundra",
"Glacier", "Glacier",
"Wetland" "Wetland",
]; ];
const color: string[] = [ const color: string[] = [
@ -38,33 +38,54 @@ class BiomesModule {
"#4b6b32", "#4b6b32",
"#96784b", "#96784b",
"#d5e7eb", "#d5e7eb",
"#0b9131" "#0b9131",
]; ];
const habitability: number[] = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; const habitability: number[] = [
const iconsDensity: number[] = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; 0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12,
const icons: Array<{[key: string]: number}> = [
{},
{dune: 3, cactus: 6, deadTree: 1},
{dune: 9, deadTree: 1},
{acacia: 1, grass: 9},
{grass: 1},
{acacia: 8, palm: 1},
{deciduous: 1},
{acacia: 5, palm: 3, deciduous: 1, swamp: 1},
{deciduous: 6, swamp: 1},
{conifer: 1},
{grass: 1},
{},
{swamp: 1}
]; ];
const cost: number[] = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost const iconsDensity: number[] = [
0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250,
];
const icons: Array<{ [key: string]: number }> = [
{},
{ dune: 3, cactus: 6, deadTree: 1 },
{ dune: 9, deadTree: 1 },
{ acacia: 1, grass: 9 },
{ grass: 1 },
{ acacia: 8, palm: 1 },
{ deciduous: 1 },
{ acacia: 5, palm: 3, deciduous: 1, swamp: 1 },
{ deciduous: 6, swamp: 1 },
{ conifer: 1 },
{ grass: 1 },
{},
{ swamp: 1 },
];
const cost: number[] = [
10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150,
]; // biome movement cost
const biomesMatrix: Uint8Array[] = [ const biomesMatrix: Uint8Array[] = [
// hot ↔ cold [>19°C; <-4°C]; dry ↕ wet // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet
new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]), new Uint8Array([
new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]), 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
new Uint8Array([5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, 10, 10]), 2, 10,
new Uint8Array([5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10]), ]),
new Uint8Array([7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10, 10]) new Uint8Array([
3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10,
10, 10,
]),
new Uint8Array([
5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10,
10, 10,
]),
new Uint8Array([
5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10,
10, 10,
]),
new Uint8Array([
7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9,
10, 10,
]),
]; ];
// parse icons weighted array into a simple array // parse icons weighted array into a simple array
@ -79,14 +100,29 @@ class BiomesModule {
parsedIcons[i] = parsed; parsedIcons[i] = parsed;
} }
return {i: range(0, name.length), name, color, biomesMatrix, habitability, iconsDensity, icons: parsedIcons, cost}; return {
i: range(0, name.length),
name,
color,
biomesMatrix,
habitability,
iconsDensity,
icons: parsedIcons,
cost,
}; };
}
define() { define() {
TIME && console.time("defineBiomes"); TIME && console.time("defineBiomes");
const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells; const {
const {temp, prec} = grid.cells; fl: flux,
r: riverIds,
h: heights,
c: neighbors,
g: gridReference,
} = pack.cells;
const { temp, prec } = grid.cells;
pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array
const calculateMoisture = (cellId: number) => { const calculateMoisture = (cellId: number) => {
@ -94,23 +130,36 @@ class BiomesModule {
if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2);
const moistAround = neighbors[cellId] const moistAround = neighbors[cellId]
.filter((neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT) .filter(
(neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT,
)
.map((c: number) => prec[gridReference[c]]) .map((c: number) => prec[gridReference[c]])
.concat([moisture]); .concat([moisture]);
return rn(4 + (mean(moistAround) as number)); return rn(4 + (mean(moistAround) as number));
} };
for (let cellId = 0; cellId < heights.length; cellId++) { for (let cellId = 0; cellId < heights.length; cellId++) {
const height = heights[cellId]; const height = heights[cellId];
const moisture = height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); const moisture =
height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId);
const temperature = temp[gridReference[cellId]]; const temperature = temp[gridReference[cellId]];
pack.cells.biome[cellId] = this.getId(moisture, temperature, height, Boolean(riverIds[cellId])); pack.cells.biome[cellId] = this.getId(
moisture,
temperature,
height,
Boolean(riverIds[cellId]),
);
} }
TIME && console.timeEnd("defineBiomes"); TIME && console.timeEnd("defineBiomes");
} }
getId(moisture: number, temperature: number, height: number, hasRiver: boolean) { getId(
moisture: number,
temperature: number,
height: number,
hasRiver: boolean,
) {
if (height < 20) return 0; // all water cells: marine biome if (height < 20) return 0; // all water cells: marine biome
if (temperature < -5) return 11; // too cold: permafrost biome if (temperature < -5) return 11; // too cold: permafrost biome
if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome

View file

@ -1,6 +1,16 @@
import { clipPoly, connectVertices, createTypedArray, distanceSquared, isLand, isWater, rn, TYPED_ARRAY_MAX_VALUES, unique } from "../utils";
import Alea from "alea"; import Alea from "alea";
import { polygonArea } from "d3"; import { polygonArea } from "d3";
import {
clipPoly,
connectVertices,
createTypedArray,
distanceSquared,
isLand,
isWater,
rn,
TYPED_ARRAY_MAX_VALUES,
unique,
} from "../utils";
declare global { declare global {
var Features: FeatureModule; var Features: FeatureModule;
@ -52,14 +62,24 @@ class FeatureModule {
/** /**
* calculate distance to coast for every cell * calculate distance to coast for every cell
*/ */
private markup({ distanceField, neighbors, start, increment, limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX }: { private markup({
distanceField,
neighbors,
start,
increment,
limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX,
}: {
distanceField: Int8Array; distanceField: Int8Array;
neighbors: number[][]; neighbors: number[][];
start: number; start: number;
increment: number; increment: number;
limit?: number; limit?: number;
}) { }) {
for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) { for (
let distance = start, marked = Infinity;
marked > 0 && distance !== limit;
distance += increment
) {
marked = 0; marked = 0;
const prevDistance = distance - increment; const prevDistance = distance - increment;
for (let cellId = 0; cellId < neighbors.length; cellId++) { for (let cellId = 0; cellId < neighbors.length; cellId++) {
@ -115,11 +135,17 @@ class FeatureModule {
const type = land ? "island" : border ? "ocean" : "lake"; const type = land ? "island" : border ? "ocean" : "lake";
features.push({ i: featureId, land, border, type }); features.push({ i: featureId, land, border, type });
queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell
} }
// markup deep ocean cells // markup deep ocean cells
this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); this.markup({
distanceField,
neighbors,
start: this.DEEP_WATER,
increment: -1,
limit: -10,
});
grid.cells.t = distanceField; grid.cells.t = distanceField;
grid.cells.f = featureIds; grid.cells.f = featureIds;
grid.features = [0, ...features]; grid.features = [0, ...features];
@ -132,15 +158,22 @@ class FeatureModule {
*/ */
markupPack() { markupPack() {
const defineHaven = (cellId: number) => { const defineHaven = (cellId: number) => {
const waterCells = neighbors[cellId].filter((index: number) => isWater(index, pack)); const waterCells = neighbors[cellId].filter((index: number) =>
const distances = waterCells.map((neibCellId: number) => distanceSquared(cells.p[cellId], cells.p[neibCellId])); isWater(index, pack),
);
const distances = waterCells.map((neibCellId: number) =>
distanceSquared(cells.p[cellId], cells.p[neibCellId]),
);
const closest = distances.indexOf(Math.min.apply(Math, distances)); const closest = distances.indexOf(Math.min.apply(Math, distances));
haven[cellId] = waterCells[closest]; haven[cellId] = waterCells[closest];
harbor[cellId] = waterCells.length; harbor[cellId] = waterCells.length;
} };
const getCellsData = (featureType: string, firstCell: number): [number, number[]] => { const getCellsData = (
featureType: string,
firstCell: number,
): [number, number[]] => {
if (featureType === "ocean") return [firstCell, []]; if (featureType === "ocean") return [firstCell, []];
const getType = (cellId: number) => featureIds[cellId]; const getType = (cellId: number) => featureIds[cellId];
@ -153,29 +186,55 @@ class FeatureModule {
return [startCell, featureVertices]; return [startCell, featureVertices];
function findOnBorderCell(firstCell: number) { function findOnBorderCell(firstCell: number) {
const isOnBorder = (cellId: number) => borderCells[cellId] || neighbors[cellId].some(ofDifferentType); const isOnBorder = (cellId: number) =>
borderCells[cellId] || neighbors[cellId].some(ofDifferentType);
if (isOnBorder(firstCell)) return firstCell; if (isOnBorder(firstCell)) return firstCell;
const startCell = cells.i.filter(ofSameType).find(isOnBorder); const startCell = cells.i.filter(ofSameType).find(isOnBorder);
if (startCell === undefined) if (startCell === undefined)
throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`); throw new Error(
`Markup: firstCell ${firstCell} is not on the feature or map border`,
);
return startCell; return startCell;
} }
function getFeatureVertices(startCell: number) { function getFeatureVertices(startCell: number) {
const startingVertex = cells.v[startCell].find((v: number) => vertices.c[v].some(ofDifferentType)); const startingVertex = cells.v[startCell].find((v: number) =>
vertices.c[v].some(ofDifferentType),
);
if (startingVertex === undefined) if (startingVertex === undefined)
throw new Error(`Markup: startingVertex for cell ${startCell} is not found`); throw new Error(
`Markup: startingVertex for cell ${startCell} is not found`,
);
return connectVertices({ vertices, startingVertex, ofSameType, closeRing: false }); return connectVertices({
} vertices,
startingVertex,
ofSameType,
closeRing: false,
});
} }
};
const addFeature = ({ firstCell, land, border, featureId, totalCells }: { firstCell: number; land: boolean; border: boolean; featureId: number; totalCells: number }): PackedGraphFeature => { const addFeature = ({
firstCell,
land,
border,
featureId,
totalCells,
}: {
firstCell: number;
land: boolean;
border: boolean;
featureId: number;
totalCells: number;
}): PackedGraphFeature => {
const type = land ? "island" : border ? "ocean" : "lake"; const type = land ? "island" : border ? "ocean" : "lake";
const [startCell, featureVertices] = getCellsData(type, firstCell); const [startCell, featureVertices] = getCellsData(type, firstCell);
const points = clipPoly(featureVertices.map((vertex: number) => vertices.p[vertex])); const points = clipPoly(
featureVertices.map((vertex: number) => vertices.p[vertex]),
);
const area = polygonArea(points); // feature perimiter area const area = polygonArea(points); // feature perimiter area
const absArea = Math.abs(rn(area)); const absArea = Math.abs(rn(area));
@ -193,20 +252,20 @@ class FeatureModule {
}; };
if (type === "lake") { if (type === "lake") {
if (area > 0) feature.vertices = (feature.vertices as number[]).reverse(); if (area > 0)
feature.vertices = (feature.vertices as number[]).reverse();
feature.shoreline = unique( feature.shoreline = unique(
(feature.vertices as number[]) (feature.vertices as number[]).flatMap((vertexIndex) =>
.flatMap( vertices.c[vertexIndex].filter((index) => isLand(index, pack)),
vertexIndex => vertices.c[vertexIndex].filter((index) => isLand(index, pack)) ),
)
); );
feature.height = Lakes.getHeight(feature as PackedGraphFeature); feature.height = Lakes.getHeight(feature as PackedGraphFeature);
} }
return { return {
...feature ...feature,
} as PackedGraphFeature; } as PackedGraphFeature;
} };
TIME && console.time("markupPack"); TIME && console.time("markupPack");
@ -217,7 +276,10 @@ class FeatureModule {
const distanceField = new Int8Array(packCellsNumber); // pack.cells.t const distanceField = new Int8Array(packCellsNumber); // pack.cells.t
const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f
const haven = createTypedArray({ maxValue: packCellsNumber, length: packCellsNumber }); // haven: opposite water cell const haven = createTypedArray({
maxValue: packCellsNumber,
length: packCellsNumber,
}); // haven: opposite water cell
const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells
const features: PackedGraphFeature[] = []; const features: PackedGraphFeature[] = [];
@ -242,9 +304,15 @@ class FeatureModule {
distanceField[neighborId] = this.WATER_COAST; distanceField[neighborId] = this.WATER_COAST;
if (!haven[cellId]) defineHaven(cellId); if (!haven[cellId]) defineHaven(cellId);
} else if (land && isNeibLand) { } else if (land && isNeibLand) {
if (distanceField[neighborId] === this.UNMARKED && distanceField[cellId] === this.LAND_COAST) if (
distanceField[neighborId] === this.UNMARKED &&
distanceField[cellId] === this.LAND_COAST
)
distanceField[neighborId] = this.LANDLOCKED; distanceField[neighborId] = this.LANDLOCKED;
else if (distanceField[cellId] === this.UNMARKED && distanceField[neighborId] === this.LAND_COAST) else if (
distanceField[cellId] === this.UNMARKED &&
distanceField[neighborId] === this.LAND_COAST
)
distanceField[cellId] = this.LANDLOCKED; distanceField[cellId] = this.LANDLOCKED;
} }
@ -256,12 +324,25 @@ class FeatureModule {
} }
} }
features.push(addFeature({ firstCell, land, border, featureId, totalCells })); features.push(
queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell addFeature({ firstCell, land, border, featureId, totalCells }),
);
queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell
} }
this.markup({ distanceField, neighbors, start: this.DEEPER_LAND, increment: 1 }); // markup pack land this.markup({
this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); // markup pack water distanceField,
neighbors,
start: this.DEEPER_LAND,
increment: 1,
}); // markup pack land
this.markup({
distanceField,
neighbors,
start: this.DEEP_WATER,
increment: -1,
limit: -10,
}); // markup pack water
pack.cells.t = distanceField; pack.cells.t = distanceField;
pack.cells.f = featureIds; pack.cells.f = featureIds;
@ -287,34 +368,40 @@ class FeatureModule {
if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; if (feature.cells > CONTINENT_MIN_SIZE) return "continent";
if (feature.cells > ISLAND_MIN_SIZE) return "island"; if (feature.cells > ISLAND_MIN_SIZE) return "island";
return "isle"; return "isle";
} };
const defineOceanGroup = (feature: PackedGraphFeature) => { const defineOceanGroup = (feature: PackedGraphFeature) => {
if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; if (feature.cells > OCEAN_MIN_SIZE) return "ocean";
if (feature.cells > SEA_MIN_SIZE) return "sea"; if (feature.cells > SEA_MIN_SIZE) return "sea";
return "gulf"; return "gulf";
} };
const defineLakeGroup = (feature: PackedGraphFeature) => { const defineLakeGroup = (feature: PackedGraphFeature) => {
if (feature.temp < -3) return "frozen"; if (feature.temp < -3) return "frozen";
if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava"; if (
feature.height > 60 &&
feature.cells < 10 &&
feature.firstCell % 10 === 0
)
return "lava";
if (!feature.inlets && !feature.outlet) { if (!feature.inlets && !feature.outlet) {
if (feature.evaporation > feature.flux * 4) return "dry"; if (feature.evaporation > feature.flux * 4) return "dry";
if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole"; if (feature.cells < 3 && feature.firstCell % 10 === 0)
return "sinkhole";
} }
if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; if (!feature.outlet && feature.evaporation > feature.flux) return "salt";
return "freshwater"; return "freshwater";
} };
const defineGroup = (feature: PackedGraphFeature) => { const defineGroup = (feature: PackedGraphFeature) => {
if (feature.type === "island") return defineIslandGroup(feature); if (feature.type === "island") return defineIslandGroup(feature);
if (feature.type === "ocean") return defineOceanGroup(feature); if (feature.type === "ocean") return defineOceanGroup(feature);
if (feature.type === "lake") return defineLakeGroup(feature); if (feature.type === "lake") return defineLakeGroup(feature);
throw new Error(`Markup: unknown feature type ${feature.type}`); throw new Error(`Markup: unknown feature type ${feature.type}`);
} };
for (const feature of pack.features) { for (const feature of pack.features) {
if (!feature || feature.type === "ocean") continue; if (!feature || feature.type === "ocean") continue;

View file

@ -1,14 +1,33 @@
import Alea from "alea"; import Alea from "alea";
import { range as d3Range, leastIndex, mean } from "d3"; import { range as d3Range, leastIndex, mean } from "d3";
import { createTypedArray, byId, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; import {
byId,
createTypedArray,
findGridCell,
getNumberInRange,
lim,
minmax,
P,
rand,
} from "../utils";
declare global { declare global {
var HeightmapGenerator: HeightmapGenerator; var HeightmapGenerator: HeightmapModule;
} }
type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth"; type Tool =
| "Hill"
| "Pit"
| "Range"
| "Trough"
| "Strait"
| "Mask"
| "Invert"
| "Add"
| "Multiply"
| "Smooth";
class HeightmapGenerator { class HeightmapModule {
grid: any = null; grid: any = null;
heights: Uint8Array | null = null; heights: Uint8Array | null = null;
blobPower: number = 0; blobPower: number = 0;
@ -17,8 +36,7 @@ class HeightmapGenerator {
private clearData() { private clearData() {
this.heights = null; this.heights = null;
this.grid = null; this.grid = null;
}; }
private getBlobPower(cells: number): number { private getBlobPower(cells: number): number {
const blobPowerMap: Record<number, number> = { const blobPowerMap: Record<number, number> = {
@ -34,7 +52,7 @@ class HeightmapGenerator {
70000: 0.9955, 70000: 0.9955,
80000: 0.996, 80000: 0.996,
90000: 0.9964, 90000: 0.9964,
100000: 0.9973 100000: 0.9973,
}; };
return blobPowerMap[cells] || 0.98; return blobPowerMap[cells] || 0.98;
} }
@ -53,7 +71,7 @@ class HeightmapGenerator {
70000: 0.88, 70000: 0.88,
80000: 0.91, 80000: 0.91,
90000: 0.92, 90000: 0.92,
100000: 0.93 100000: 0.93,
}; };
return linePowerMap[cells] || 0.81; return linePowerMap[cells] || 0.81;
@ -65,26 +83,31 @@ class HeightmapGenerator {
return; return;
} }
const min = parseInt(range.split("-")[0]) / 100 || 0; const min = parseInt(range.split("-")[0], 10) / 100 || 0;
const max = parseInt(range.split("-")[1]) / 100 || min; const max = parseInt(range.split("-")[1], 10) / 100 || min;
return rand(min * length, max * length); return rand(min * length, max * length);
} }
setGraph(graph: any) { setGraph(graph: any) {
const {cellsDesired, cells, points} = graph; const { cellsDesired, cells, points } = graph;
this.heights = cells.h ? Uint8Array.from(cells.h) : createTypedArray({maxValue: 100, length: points.length}) as Uint8Array; this.heights = cells.h
? Uint8Array.from(cells.h)
: (createTypedArray({
maxValue: 100,
length: points.length,
}) as Uint8Array);
this.blobPower = this.getBlobPower(cellsDesired); this.blobPower = this.getBlobPower(cellsDesired);
this.linePower = this.getLinePower(cellsDesired); this.linePower = this.getLinePower(cellsDesired);
this.grid = graph; this.grid = graph;
}; }
addHill(count: string, height: string, rangeX: string, rangeY: string): void { addHill(count: string, height: string, rangeX: string, rangeY: string): void {
const addOneHill = () => { const addOneHill = () => {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
const change = new Uint8Array(this.heights.length); const change = new Uint8Array(this.heights.length);
let limit = 0; let limit = 0;
let start: number; let start: number;
let h = lim(getNumberInRange(height)); const h = lim(getNumberInRange(height));
do { do {
const x = this.getPointInRange(rangeX, graphWidth); const x = this.getPointInRange(rangeX, graphWidth);
@ -106,17 +129,17 @@ class HeightmapGenerator {
} }
this.heights = this.heights.map((h, i) => lim(h + change[i])); this.heights = this.heights.map((h, i) => lim(h + change[i]));
} };
const desiredHillCount = getNumberInRange(count); const desiredHillCount = getNumberInRange(count);
for (let i = 0; i < desiredHillCount; i++) { for (let i = 0; i < desiredHillCount; i++) {
addOneHill(); addOneHill();
} }
}; }
addPit(count: string, height: string, rangeX: string, rangeY: string): void { addPit(count: string, height: string, rangeX: string, rangeY: string): void {
const addOnePit = () => { const addOnePit = () => {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
const used = new Uint8Array(this.heights.length); const used = new Uint8Array(this.heights.length);
let limit = 0; let limit = 0;
let start: number; let start: number;
@ -138,24 +161,33 @@ class HeightmapGenerator {
this.grid.cells.c[q].forEach((c: number) => { this.grid.cells.c[q].forEach((c: number) => {
if (used[c] || this.heights === null) return; if (used[c] || this.heights === null) return;
this.heights[c] = lim(this.heights[c] - h * (Math.random() * 0.2 + 0.9)); this.heights[c] = lim(
this.heights[c] - h * (Math.random() * 0.2 + 0.9),
);
used[c] = 1; used[c] = 1;
queue.push(c); queue.push(c);
}); });
} }
} };
const desiredPitCount = getNumberInRange(count); const desiredPitCount = getNumberInRange(count);
for (let i = 0; i < desiredPitCount; i++) { for (let i = 0; i < desiredPitCount; i++) {
addOnePit(); addOnePit();
} }
}; }
addRange(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { addRange(
if(!this.heights || !this.grid) return; count: string,
height: string,
rangeX: string,
rangeY: string,
startCellId?: number,
endCellId?: number,
): void {
if (!this.heights || !this.grid) return;
const addOneRange = () => { const addOneRange = () => {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
// get main ridge // get main ridge
const getRange = (cur: number, end: number) => { const getRange = (cur: number, end: number) => {
@ -180,7 +212,7 @@ class HeightmapGenerator {
} }
return range; return range;
} };
const used = new Uint8Array(this.heights.length); const used = new Uint8Array(this.heights.length);
let h = lim(getNumberInRange(height)); let h = lim(getNumberInRange(height));
@ -192,32 +224,37 @@ class HeightmapGenerator {
let dist = 0; let dist = 0;
let limit = 0; let limit = 0;
let endY; let endY: number;
let endX; let endX: number;
do { do {
endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1;
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX); dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++; limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50); } while (
(dist < graphWidth / 8 || dist > graphWidth / 3) &&
limit < 50
);
startCellId = findGridCell(startX, startY, this.grid); startCellId = findGridCell(startX, startY, this.grid);
endCellId = findGridCell(endX, endY, this.grid); endCellId = findGridCell(endX, endY, this.grid);
} }
let range = getRange(startCellId as number, endCellId as number); const range = getRange(startCellId as number, endCellId as number);
// add height to ridge and cells around // add height to ridge and cells around
let queue = range.slice(); let queue = range.slice();
let i = 0; let i = 0;
while (queue.length) { while (queue.length) {
const frontier = queue.slice(); const frontier = queue.slice();
(queue = []), i++; queue = [];
i++;
frontier.forEach((i: number) => { frontier.forEach((i: number) => {
if(!this.heights) return; if (!this.heights) return;
this.heights[i] = lim(this.heights[i] + h * (Math.random() * 0.3 + 0.85)); this.heights[i] = lim(
this.heights[i] + h * (Math.random() * 0.3 + 0.85),
);
}); });
h = h ** this.linePower - 1; h = h ** this.linePower - 1;
if (h < 2) break; if (h < 2) break;
@ -235,24 +272,35 @@ class HeightmapGenerator {
range.forEach((cur: number, d: number) => { range.forEach((cur: number, d: number) => {
if (d % 6 !== 0) return; if (d % 6 !== 0) return;
for (const _l of d3Range(i)) { for (const _l of d3Range(i)) {
const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); const index = leastIndex(
if(index === undefined) continue; 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 const min = this.grid.cells.c[cur][index]; // downhill cell
this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; this.heights![min] =
(this.heights![cur] * 2 + this.heights![min]) / 3;
cur = min; cur = min;
} }
}); });
} };
const desiredRangeCount = getNumberInRange(count); const desiredRangeCount = getNumberInRange(count);
for (let i = 0; i < desiredRangeCount; i++) { for (let i = 0; i < desiredRangeCount; i++) {
addOneRange(); addOneRange();
} }
}; }
addTrough(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { addTrough(
count: string,
height: string,
rangeX: string,
rangeY: string,
startCellId?: number,
endCellId?: number,
): void {
const addOneTrough = () => { const addOneTrough = () => {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
// get main ridge // get main ridge
const getRange = (cur: number, end: number) => { const getRange = (cur: number, end: number) => {
@ -277,7 +325,7 @@ class HeightmapGenerator {
} }
return range; return range;
} };
const used = new Uint8Array(this.heights.length); const used = new Uint8Array(this.heights.length);
let h = lim(getNumberInRange(height)); let h = lim(getNumberInRange(height));
@ -303,22 +351,27 @@ class HeightmapGenerator {
endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15;
dist = Math.abs(endY - startY) + Math.abs(endX - startX); dist = Math.abs(endY - startY) + Math.abs(endX - startX);
limit++; limit++;
} while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50); } while (
(dist < graphWidth / 8 || dist > graphWidth / 2) &&
limit < 50
);
endCellId = findGridCell(endX, endY, this.grid); endCellId = findGridCell(endX, endY, this.grid);
} }
let range = getRange(startCellId as number, endCellId as number); const range = getRange(startCellId as number, endCellId as number);
// add height to ridge and cells around // add height to ridge and cells around
let queue = range.slice(), let queue = range.slice(),
i = 0; i = 0;
while (queue.length) { while (queue.length) {
const frontier = queue.slice(); const frontier = queue.slice();
(queue = []), i++; queue = [];
i++;
frontier.forEach((i: number) => { frontier.forEach((i: number) => {
this.heights![i] = lim(this.heights![i] - h * (Math.random() * 0.3 + 0.85)); this.heights![i] = lim(
this.heights![i] - h * (Math.random() * 0.3 + 0.85),
);
}); });
h = h ** this.linePower - 1; h = h ** this.linePower - 1;
if (h < 2) break; if (h < 2) break;
@ -336,36 +389,57 @@ class HeightmapGenerator {
range.forEach((cur: number, d: number) => { range.forEach((cur: number, d: number) => {
if (d % 6 !== 0) return; if (d % 6 !== 0) return;
for (const _l of d3Range(i)) { for (const _l of d3Range(i)) {
const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); const index = leastIndex(
if(index === undefined) continue; 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 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); //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; this.heights![min] =
(this.heights![cur] * 2 + this.heights![min]) / 3;
cur = min; cur = min;
} }
}); });
}
const desiredTroughCount = getNumberInRange(count);
for(let i = 0; i < desiredTroughCount; i++) {
addOneTrough();
}
}; };
const desiredTroughCount = getNumberInRange(count);
for (let i = 0; i < desiredTroughCount; i++) {
addOneTrough();
}
}
addStrait(width: string, direction = "vertical"): void { addStrait(width: string, direction = "vertical"): void {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
const desiredWidth = Math.min(getNumberInRange(width), this.grid.cellsX / 3); const desiredWidth = Math.min(
getNumberInRange(width),
this.grid.cellsX / 3,
);
if (desiredWidth < 1 && P(desiredWidth)) return; if (desiredWidth < 1 && P(desiredWidth)) return;
const used = new Uint8Array(this.heights.length); const used = new Uint8Array(this.heights.length);
const vert = direction === "vertical"; const vert = direction === "vertical";
const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5; const startX = vert
const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3)
: 5;
const startY = vert
? 5
: Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3);
const endX = vert const endX = vert
? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2) ? Math.floor(
graphWidth -
startX -
graphWidth * 0.1 +
Math.random() * graphWidth * 0.2,
)
: graphWidth - 5; : graphWidth - 5;
const endY = vert const endY = vert
? graphHeight - 5 ? graphHeight - 5
: Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2); : Math.floor(
graphHeight -
startY -
graphHeight * 0.1 +
Math.random() * graphHeight * 0.2,
);
const start = findGridCell(startX, startY, this.grid); const start = findGridCell(startX, startY, this.grid);
const end = findGridCell(endX, endY, this.grid); const end = findGridCell(endX, endY, this.grid);
@ -388,14 +462,13 @@ class HeightmapGenerator {
} }
return range; return range;
} };
let range = getRange(start, end); let range = getRange(start, end);
const query: number[] = []; const query: number[] = [];
const step = 0.1 / desiredWidth; const step = 0.1 / desiredWidth;
for(let i = 0; i < desiredWidth; i++) { for (let i = 0; i < desiredWidth; i++) {
const exp = 0.9 - step * desiredWidth; const exp = 0.9 - step * desiredWidth;
range.forEach((r: number) => { range.forEach((r: number) => {
this.grid.cells.c[r].forEach((e: number) => { this.grid.cells.c[r].forEach((e: number) => {
@ -408,15 +481,17 @@ class HeightmapGenerator {
}); });
range = query.slice(); range = query.slice();
} }
}; }
modify(range: string, add: number, mult: number, power?: number): void { modify(range: string, add: number, mult: number, power?: number): void {
if(!this.heights) return; if (!this.heights) return;
const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; const min =
const max = range === "land" || range === "all" ? 100 : +range.split("-")[1]; range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0];
const max =
range === "land" || range === "all" ? 100 : +range.split("-")[1];
const isLand = min === 20; const isLand = min === 20;
this.heights = this.heights.map(h => { this.heights = this.heights.map((h) => {
if (h < min || h > max) return h; if (h < min || h > max) return h;
if (add) h = isLand ? Math.max(h + add, 20) : h + add; if (add) h = isLand ? Math.max(h + add, 20) : h + add;
@ -424,20 +499,22 @@ class HeightmapGenerator {
if (power) h = isLand ? (h - 20) ** power + 20 : h ** power; if (power) h = isLand ? (h - 20) ** power + 20 : h ** power;
return lim(h); return lim(h);
}); });
}; }
smooth(fr = 2, add = 0): void { smooth(fr = 2, add = 0): void {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
this.heights = this.heights.map((h, i) => { this.heights = this.heights.map((h, i) => {
const a = [h]; const a = [h];
this.grid.cells.c[i].forEach((c: number) => a.push(this.heights![c])); this.grid.cells.c[i].forEach((c: number) => {
a.push(this.heights![c]);
});
if (fr === 1) return (mean(a) as number) + add; if (fr === 1) return (mean(a) as number) + add;
return lim((h * (fr - 1) + (mean(a) as number) + add) / fr); return lim((h * (fr - 1) + (mean(a) as number) + add) / fr);
}); });
}; }
mask(power = 1): void { mask(power = 1): void {
if(!this.heights || !this.grid) return; if (!this.heights || !this.grid) return;
const fr = power ? Math.abs(power) : 1; const fr = power ? Math.abs(power) : 1;
this.heights = this.heights.map((h, i) => { this.heights = this.heights.map((h, i) => {
@ -449,17 +526,17 @@ class HeightmapGenerator {
const masked = h * distance; const masked = h * distance;
return lim((h * (fr - 1) + masked) / fr); return lim((h * (fr - 1) + masked) / fr);
}); });
}; }
invert(count: number, axes: string): void { invert(count: number, axes: string): void {
if (!P(count) || !this.heights || !this.grid) return; if (!P(count) || !this.heights || !this.grid) return;
const invertX = axes !== "y"; const invertX = axes !== "y";
const invertY = axes !== "x"; const invertY = axes !== "x";
const {cellsX, cellsY} = this.grid; const { cellsX, cellsY } = this.grid;
const inverted = this.heights.map((_h: number, i: number) => { const inverted = this.heights.map((_h: number, i: number) => {
if(!this.heights) return 0; if (!this.heights) return 0;
const x = i % cellsX; const x = i % cellsX;
const y = Math.floor(i / cellsX); const y = Math.floor(i / cellsX);
@ -470,29 +547,60 @@ class HeightmapGenerator {
}); });
this.heights = inverted; this.heights = inverted;
}; }
addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void { addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void {
if (tool === "Hill") return this.addHill(a2, a3, a4, a5); if (tool === "Hill") {
if (tool === "Pit") return this.addPit(a2, a3, a4, a5); this.addHill(a2, a3, a4, a5);
if (tool === "Range") return this.addRange(a2, a3, a4, a5); return;
if (tool === "Trough") return this.addTrough(a2, a3, a4, a5); }
if (tool === "Strait") return this.addStrait(a2, a3); if (tool === "Pit") {
if (tool === "Mask") return this.mask(+a2); this.addPit(a2, a3, a4, a5);
if (tool === "Invert") return this.invert(+a2, a3); return;
if (tool === "Add") return this.modify(a3, +a2, 1); }
if (tool === "Multiply") return this.modify(a3, 0, +a2); if (tool === "Range") {
if (tool === "Smooth") return this.smooth(+a2); this.addRange(a2, a3, a4, a5);
return;
}
if (tool === "Trough") {
this.addTrough(a2, a3, a4, a5);
return;
}
if (tool === "Strait") {
this.addStrait(a2, a3);
return;
}
if (tool === "Mask") {
this.mask(+a2);
return;
}
if (tool === "Invert") {
this.invert(+a2, a3);
return;
}
if (tool === "Add") {
this.modify(a3, +a2, 1);
return;
}
if (tool === "Multiply") {
this.modify(a3, 0, +a2);
return;
}
if (tool === "Smooth") {
this.smooth(+a2);
return;
}
} }
async generate(graph: any): Promise<Uint8Array> { async generate(graph: any): Promise<Uint8Array> {
TIME && console.time("defineHeightmap"); TIME && console.time("defineHeightmap");
const id = (byId("templateInput")! as HTMLInputElement).value; const id = (byId("templateInput")! as HTMLInputElement).value;
Math.random = Alea(seed); Math.random = Alea(seed);
const isTemplate = id in heightmapTemplates; const isTemplate = id in heightmapTemplates;
const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id); const heights = isTemplate
? this.fromTemplate(graph, id)
: await this.fromPrecreated(graph, id);
TIME && console.timeEnd("defineHeightmap"); TIME && console.timeEnd("defineHeightmap");
this.clearData(); this.clearData();
@ -503,33 +611,40 @@ class HeightmapGenerator {
const templateString = heightmapTemplates[id]?.template || ""; const templateString = heightmapTemplates[id]?.template || "";
const steps = templateString.split("\n"); const steps = templateString.split("\n");
if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`); if (!steps.length)
throw new Error(
`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`,
);
this.setGraph(graph); this.setGraph(graph);
for (const step of steps) { for (const step of steps) {
const elements = step.trim().split(" "); const elements = step.trim().split(" ");
if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`); if (elements.length < 2)
this.addStep(...elements as [Tool, string, string, string, string]); throw new Error(
`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`,
);
this.addStep(...(elements as [Tool, string, string, string, string]));
} }
return this.heights; return this.heights;
}; }
private getHeightsFromImageData(imageData: Uint8ClampedArray): void { private getHeightsFromImageData(imageData: Uint8ClampedArray): void {
if(!this.heights) return; if (!this.heights) return;
for (let i = 0; i < this.heights.length; i++) { for (let i = 0; i < this.heights.length; i++) {
const lightness = imageData[i * 4] / 255; const lightness = imageData[i * 4] / 255;
const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; const powered =
lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8;
this.heights[i] = minmax(Math.floor(powered * 100), 0, 100); this.heights[i] = minmax(Math.floor(powered * 100), 0, 100);
} }
} }
fromPrecreated(graph: any, id: string): Promise<Uint8Array> { fromPrecreated(graph: any, id: string): Promise<Uint8Array> {
return new Promise(resolve => { return new Promise((resolve) => {
// create canvas where 1px corresponds to a cell // create canvas where 1px corresponds to a cell
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
const {cellsX, cellsY} = graph; const { cellsX, cellsY } = graph;
canvas.width = cellsX; canvas.width = cellsX;
canvas.height = cellsY; canvas.height = cellsY;
@ -537,7 +652,7 @@ class HeightmapGenerator {
const img = new Image(); const img = new Image();
img.src = `./heightmaps/${id}.png`; img.src = `./heightmaps/${id}.png`;
img.onload = () => { img.onload = () => {
if(!ctx) { if (!ctx) {
throw new Error("Could not get canvas context"); throw new Error("Could not get canvas context");
} }
this.heights = this.heights || new Uint8Array(cellsX * cellsY); this.heights = this.heights || new Uint8Array(cellsX * cellsY);
@ -550,11 +665,11 @@ class HeightmapGenerator {
resolve(this.heights); resolve(this.heights);
}; };
}); });
}; }
getHeights() { getHeights() {
return this.heights; return this.heights;
} }
} }
window.HeightmapGenerator = new HeightmapGenerator(); window.HeightmapGenerator = new HeightmapModule();

View file

@ -5,4 +5,4 @@ import "./names-generator";
import "./ocean-layers"; import "./ocean-layers";
import "./lakes"; import "./lakes";
import "./river-generator"; import "./river-generator";
import "./biomes" import "./biomes";

View file

@ -1,7 +1,6 @@
import { PackedGraphFeature } from "./features"; import { mean, min } from "d3";
import { min, mean } from "d3"; import { byId, rn } from "../utils";
import { byId, import type { PackedGraphFeature } from "./features";
rn } from "../utils";
declare global { declare global {
var Lakes: LakesModule; var Lakes: LakesModule;
@ -12,24 +11,25 @@ export class LakesModule {
getHeight(feature: PackedGraphFeature) { getHeight(feature: PackedGraphFeature) {
const heights = pack.cells.h; const heights = pack.cells.h;
const minShoreHeight = min(feature.shoreline.map(cellId => heights[cellId])) || 20; const minShoreHeight =
min(feature.shoreline.map((cellId) => heights[cellId])) || 20;
return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2); return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2);
}; }
defineNames() { defineNames() {
pack.features.forEach((feature: PackedGraphFeature) => { pack.features.forEach((feature: PackedGraphFeature) => {
if (feature.type !== "lake") return; if (feature.type !== "lake") return;
feature.name = this.getName(feature); feature.name = this.getName(feature);
}); });
}; }
getName(feature: PackedGraphFeature): string { getName(feature: PackedGraphFeature): string {
const landCell = feature.shoreline[0]; const landCell = feature.shoreline[0];
const culture = pack.cells.culture[landCell]; const culture = pack.cells.culture[landCell];
return Names.getCulture(culture); return Names.getCulture(culture);
}; }
cleanupLakeData = function () { cleanupLakeData = () => {
for (const feature of pack.features) { for (const feature of pack.features) {
if (feature.type !== "lake") continue; if (feature.type !== "lake") continue;
delete feature.river; delete feature.river;
@ -38,39 +38,50 @@ export class LakesModule {
delete feature.closed; delete feature.closed;
feature.height = rn(feature.height, 3); feature.height = rn(feature.height, 3);
const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r)); const inlets = feature.inlets?.filter((r) =>
pack.rivers.find((river) => river.i === r),
);
if (!inlets || !inlets.length) delete feature.inlets; if (!inlets || !inlets.length) delete feature.inlets;
else feature.inlets = inlets; else feature.inlets = inlets;
const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet); const outlet =
feature.outlet &&
pack.rivers.find((river) => river.i === feature.outlet);
if (!outlet) delete feature.outlet; if (!outlet) delete feature.outlet;
} }
}; };
defineClimateData(heights: number[] | Uint8Array) { defineClimateData(heights: number[] | Uint8Array) {
const {cells, features} = pack; const { cells, features } = pack;
const lakeOutCells = new Uint16Array(cells.i.length); const lakeOutCells = new Uint16Array(cells.i.length);
const getFlux = (lake: PackedGraphFeature) => { const getFlux = (lake: PackedGraphFeature) => {
return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0); return lake.shoreline.reduce(
} (acc, c) => acc + grid.cells.prec[cells.g[c]],
0,
);
};
const getLakeTemp = (lake: PackedGraphFeature) => { const getLakeTemp = (lake: PackedGraphFeature) => {
if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]];
return rn(mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])) as number, 1); return rn(
} mean(lake.shoreline.map((c) => grid.cells.temp[cells.g[c]])) as number,
1,
);
};
const getLakeEvaporation = (lake: PackedGraphFeature) => { const getLakeEvaporation = (lake: PackedGraphFeature) => {
const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters
const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] const evaporation =
((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11]
return rn(evaporation * lake.cells); return rn(evaporation * lake.cells);
} };
const getLowestShoreCell = (lake: PackedGraphFeature) => { const getLowestShoreCell = (lake: PackedGraphFeature) => {
return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0];
} };
features.forEach(feature => { features.forEach((feature) => {
if (feature.type !== "lake") return; if (feature.type !== "lake") return;
feature.flux = getFlux(feature); feature.flux = getFlux(feature);
feature.temp = getLakeTemp(feature); feature.temp = getLakeTemp(feature);
@ -82,14 +93,16 @@ export class LakesModule {
}); });
return lakeOutCells; return lakeOutCells;
}; }
// check if lake can be potentially open (not in deep depression) // check if lake can be potentially open (not in deep depression)
detectCloseLakes(h: number[] | Uint8Array) { detectCloseLakes(h: number[] | Uint8Array) {
const {cells} = pack; const { cells } = pack;
const ELEVATION_LIMIT = +(byId("lakeElevationLimitOutput") as HTMLInputElement)?.value; const ELEVATION_LIMIT = +(
byId("lakeElevationLimitOutput") as HTMLInputElement
)?.value;
pack.features.forEach(feature => { pack.features.forEach((feature) => {
if (feature.type !== "lake") return; if (feature.type !== "lake") return;
delete feature.closed; delete feature.closed;
@ -100,7 +113,9 @@ export class LakesModule {
} }
let isDeep = true; let isDeep = true;
const lowestShorelineCell = feature.shoreline.sort((a, b) => h[a] - h[b])[0]; const lowestShorelineCell = feature.shoreline.sort(
(a, b) => h[a] - h[b],
)[0];
const queue = [lowestShorelineCell]; const queue = [lowestShorelineCell];
const checked = []; const checked = [];
checked[lowestShorelineCell] = true; checked[lowestShorelineCell] = true;
@ -114,7 +129,8 @@ export class LakesModule {
if (h[neibCellId] < 20) { if (h[neibCellId] < 20) {
const nFeature = pack.features[cells.f[neibCellId]]; const nFeature = pack.features[cells.f[neibCellId]];
if (nFeature.type === "ocean" || feature.height > nFeature.height) isDeep = false; if (nFeature.type === "ocean" || feature.height > nFeature.height)
isDeep = false;
} }
checked[neibCellId] = true; checked[neibCellId] = true;
@ -124,7 +140,7 @@ export class LakesModule {
feature.closed = isDeep; feature.closed = isDeep;
}); });
}; }
} }
window.Lakes = new LakesModule(); window.Lakes = new LakesModule();

View file

@ -1,6 +1,6 @@
import { line, curveBasisClosed } from 'd3'; import type { Selection } from "d3";
import type { Selection } from 'd3'; import { curveBasisClosed, line } from "d3";
import { clipPoly,P,rn,round } from '../utils'; import { clipPoly, P, rn, round } from "../utils";
declare global { declare global {
var OceanLayers: typeof OceanModule.prototype.draw; var OceanLayers: typeof OceanModule.prototype.draw;
@ -13,7 +13,6 @@ class OceanModule {
private lineGen = line().curve(curveBasisClosed); private lineGen = line().curve(curveBasisClosed);
private oceanLayers: Selection<SVGGElement, unknown, null, undefined>; private oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
constructor(oceanLayers: Selection<SVGGElement, unknown, null, undefined>) { constructor(oceanLayers: Selection<SVGGElement, unknown, null, undefined>) {
this.oceanLayers = oceanLayers; this.oceanLayers = oceanLayers;
} }
@ -35,11 +34,17 @@ class OceanModule {
// connect vertices to chain // connect vertices to chain
connectVertices(start: number, t: number) { connectVertices(start: number, t: number) {
const chain = []; // vertices chain to form a path const chain = []; // vertices chain to form a path
for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) { for (
let i = 0, current = start;
i === 0 || (current !== start && i < 10000);
i++
) {
const prev = chain[chain.length - 1]; // previous vertex in chain const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence chain.push(current); // add current vertex to sequence
const c = this.vertices.c[current]; // cells adjacent to vertex const c = this.vertices.c[current]; // cells adjacent to vertex
c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => (this.used[c] = 1)); c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => {
this.used[c] = 1;
});
const v = this.vertices.v[current]; // neighboring vertices const v = this.vertices.v[current]; // neighboring vertices
const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1; const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1;
const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1; const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1;
@ -58,8 +63,15 @@ class OceanModule {
// find eligible cell vertex to start path detection // find eligible cell vertex to start path detection
findStart(i: number, t: number) { findStart(i: number, t: number) {
if (this.cells.b[i]) return this.cells.v[i].find((v: number) => this.vertices.c[v].some((c: number) => c >= this.pointsN)); // map border cell if (this.cells.b[i])
return this.cells.v[i][this.cells.c[i].findIndex((c: number)=> this.cells.t[c] < t || !this.cells.t[c])]; return this.cells.v[i].find((v: number) =>
this.vertices.c[v].some((c: number) => c >= this.pointsN),
); // map border cell
return this.cells.v[i][
this.cells.c[i].findIndex(
(c: number) => this.cells.t[c] < t || !this.cells.t[c],
)
];
} }
draw() { draw() {
@ -69,7 +81,10 @@ class OceanModule {
this.cells = grid.cells; this.cells = grid.cells;
this.pointsN = grid.cells.i.length; this.pointsN = grid.cells.i.length;
this.vertices = grid.vertices; this.vertices = grid.vertices;
const limits = outline === "random" ? this.randomizeOutline() : outline.split(",").map((s: string) => +s); const limits =
outline === "random"
? this.randomizeOutline()
: outline.split(",").map((s: string) => +s);
const chains: [number, any[]][] = []; const chains: [number, any[]][] = [];
const opacity = rn(0.4 / limits.length, 2); const opacity = rn(0.4 / limits.length, 2);
@ -85,22 +100,33 @@ class OceanModule {
const chain = this.connectVertices(start, t); // vertices chain to form a path const chain = this.connectVertices(start, t); // vertices chain to form a path
if (chain.length < 4) continue; if (chain.length < 4) continue;
const relax = 1 + t * -2; // select only n-th point const relax = 1 + t * -2; // select only n-th point
const relaxed = chain.filter((v, i) => !(i % relax) || this.vertices.c[v].some((c: number) => c >= this.pointsN)); const relaxed = chain.filter(
(v, i) =>
!(i % relax) ||
this.vertices.c[v].some((c: number) => c >= this.pointsN),
);
if (relaxed.length < 4) continue; if (relaxed.length < 4) continue;
const points = clipPoly( const points = clipPoly(
relaxed.map(v => this.vertices.p[v]), relaxed.map((v) => this.vertices.p[v]),
graphWidth, graphWidth,
graphHeight, graphHeight,
1 1,
); );
chains.push([t, points]); chains.push([t, points]);
} }
for (const t of limits) { for (const t of limits) {
const layer = chains.filter((c: [number, any[]]) => c[0] === t); const layer = chains.filter((c: [number, any[]]) => c[0] === t);
let path = layer.map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")).join(""); const path = layer
if (path) this.oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity); .map((c: [number, any[]]) => round(this.lineGen(c[1]) || ""))
.join("");
if (path)
this.oceanLayers
.append("path")
.attr("d", path)
.attr("fill", "#ecf2f9")
.attr("fill-opacity", opacity);
} }
TIME && console.timeEnd("drawOceanLayers"); TIME && console.timeEnd("drawOceanLayers");

View file

@ -1,8 +1,6 @@
import Alea from "alea"; import Alea from "alea";
import { each, rn, round, rw} from "../utils"; import { curveBasis, curveCatmullRom, line, mean, min, sum } from "d3";
import { curveBasis, line, mean, min, sum, curveCatmullRom } from "d3"; import { each, rn, round, rw } from "../utils";
declare global { declare global {
var Rivers: RiverModule; var Rivers: RiverModule;
@ -29,18 +27,20 @@ class RiverModule {
private MAX_FLUX_WIDTH = 1; private MAX_FLUX_WIDTH = 1;
private LENGTH_FACTOR = 200; private LENGTH_FACTOR = 200;
private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR; private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR;
private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / this.LENGTH_FACTOR); private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(
private lineGen = line().curve(curveBasis) (n) => n / this.LENGTH_FACTOR,
);
private lineGen = line().curve(curveBasis);
riverTypes = { riverTypes = {
main: { main: {
big: {River: 1}, big: { River: 1 },
small: {Creek: 9, River: 3, Brook: 3, Stream: 1} small: { Creek: 9, River: 3, Brook: 3, Stream: 1 },
}, },
fork: { fork: {
big: {Fork: 1}, big: { Fork: 1 },
small: {Branch: 1} small: { Branch: 1 },
} },
}; };
smallLength: number | null = null; smallLength: number | null = null;
@ -48,10 +48,10 @@ class RiverModule {
generate(allowErosion = true) { generate(allowErosion = true) {
TIME && console.time("generateRivers"); TIME && console.time("generateRivers");
Math.random = Alea(seed); Math.random = Alea(seed);
const {cells, features} = pack; const { cells, features } = pack;
const riversData: {[riverId: number]: number[]} = {}; const riversData: { [riverId: number]: number[] } = {};
const riverParents: {[key: number]: number} = {}; const riverParents: { [key: number]: number } = {};
const addCellToRiver = (cellId: number, riverId: number) => { const addCellToRiver = (cellId: number, riverId: number) => {
if (!riversData[riverId]) riversData[riverId] = [cellId]; if (!riversData[riverId]) riversData[riverId] = [cellId];
@ -60,26 +60,36 @@ class RiverModule {
const drainWater = () => { const drainWater = () => {
const MIN_FLUX_TO_FORM_RIVER = 30; const MIN_FLUX_TO_FORM_RIVER = 30;
const cellsNumberModifier = ((pointsInput.dataset.cells as any) / 10000) ** 0.25; const cellsNumberModifier =
((pointsInput.dataset.cells as any) / 10000) ** 0.25;
const prec = grid.cells.prec; const prec = grid.cells.prec;
const land = cells.i.filter((i: number) => h[i] >= 20).sort((a: number, b: number) => h[b] - h[a]); const land = cells.i
.filter((i: number) => h[i] >= 20)
.sort((a: number, b: number) => h[b] - h[a]);
const lakeOutCells = Lakes.defineClimateData(h); const lakeOutCells = Lakes.defineClimateData(h);
land.forEach(function (i: number) { for (const i of land) {
cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation
// create lake outlet if lake is not in deep depression and flux > evaporation // create lake outlet if lake is not in deep depression and flux > evaporation
const lakes = lakeOutCells[i] const lakes = lakeOutCells[i]
? features.filter((feature: any) => i === feature.outCell && feature.flux > feature.evaporation) ? features.filter(
(feature: any) =>
i === feature.outCell && feature.flux > feature.evaporation,
)
: []; : [];
for (const lake of lakes) { for (const lake of lakes) {
const lakeCell = cells.c[i].find((c: number) => h[c] < 20 && cells.f[c] === lake.i)!; const lakeCell = cells.c[i].find(
(c: number) => h[c] < 20 && cells.f[c] === lake.i,
)!;
cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet
// allow chain lakes to retain identity // allow chain lakes to retain identity
if (cells.r[lakeCell] !== lake.river) { if (cells.r[lakeCell] !== lake.river) {
const sameRiver = cells.c[lakeCell].some((c: number) => cells.r[c] === lake.river); const sameRiver = cells.c[lakeCell].some(
(c: number) => cells.r[c] === lake.river,
);
if (sameRiver) { if (sameRiver) {
cells.r[lakeCell] = lake.river as number; cells.r[lakeCell] = lake.river as number;
@ -105,12 +115,18 @@ class RiverModule {
} }
// near-border cell: pour water out of the screen // near-border cell: pour water out of the screen
if (cells.b[i] && cells.r[i]) return addCellToRiver(-1, cells.r[i]); if (cells.b[i] && cells.r[i]) {
addCellToRiver(-1, cells.r[i]);
continue;
}
// downhill cell (make sure it's not in the source lake) // downhill cell (make sure it's not in the source lake)
let min = null; let min = null;
if (lakeOutCells[i]) { if (lakeOutCells[i]) {
const filtered = cells.c[i].filter((c: number) => !lakes.map((lake: any) => lake.i).includes(cells.f[c])); const filtered = cells.c[i].filter(
(c: number) =>
!lakes.map((lake: any) => lake.i).includes(cells.f[c]),
);
min = filtered.sort((a: number, b: number) => h[a] - h[b])[0]; min = filtered.sort((a: number, b: number) => h[a] - h[b])[0];
} else if (cells.haven[i]) { } else if (cells.haven[i]) {
min = cells.haven[i]; min = cells.haven[i];
@ -119,7 +135,7 @@ class RiverModule {
} }
// cells is depressed // cells is depressed
if (h[i] <= h[min]) return; if (h[i] <= h[min]) continue;
// debug // debug
// .append("line") // .append("line")
@ -133,7 +149,7 @@ class RiverModule {
if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) { if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) {
// flux is too small to operate as a river // flux is too small to operate as a river
if (h[min] >= 20) cells.fl[min] += cells.fl[i]; if (h[min] >= 20) cells.fl[min] += cells.fl[i];
return; continue;
} }
// proclaim a new river // proclaim a new river
@ -144,8 +160,8 @@ class RiverModule {
} }
flowDown(min, cells.fl[i], cells.r[i]); flowDown(min, cells.fl[i], cells.r[i]);
});
} }
};
const flowDown = (toCell: number, fromFlux: number, river: number) => { const flowDown = (toCell: number, fromFlux: number, river: number) => {
const toFlux = cells.fl[toCell] - cells.conf[toCell]; const toFlux = cells.fl[toCell] - cells.conf[toCell];
@ -167,7 +183,10 @@ class RiverModule {
// pour water to the water body // pour water to the water body
const waterBody = features[cells.f[toCell]]; const waterBody = features[cells.f[toCell]];
if (waterBody.type === "lake") { if (waterBody.type === "lake") {
if (!waterBody.river || fromFlux > (waterBody.enteringFlux as number)) { if (
!waterBody.river ||
fromFlux > (waterBody.enteringFlux as number)
) {
waterBody.river = river; waterBody.river = river;
waterBody.enteringFlux = fromFlux; waterBody.enteringFlux = fromFlux;
} }
@ -181,7 +200,7 @@ class RiverModule {
} }
addCellToRiver(toCell, river); addCellToRiver(toCell, river);
} };
const defineRivers = () => { const defineRivers = () => {
// re-initialize rivers and confluence arrays // re-initialize rivers and confluence arrays
@ -189,7 +208,10 @@ class RiverModule {
cells.conf = new Uint16Array(cells.i.length); cells.conf = new Uint16Array(cells.i.length);
pack.rivers = []; pack.rivers = [];
const defaultWidthFactor = rn(1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, 2); const defaultWidthFactor = rn(
1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25,
2,
);
const mainStemWidthFactor = defaultWidthFactor * 1.2; const mainStemWidthFactor = defaultWidthFactor * 1.2;
for (const key in riversData) { for (const key in riversData) {
@ -209,7 +231,10 @@ class RiverModule {
const mouth = riverCells[riverCells.length - 2]; const mouth = riverCells[riverCells.length - 2];
const parent = riverParents[key] || 0; const parent = riverParents[key] || 0;
const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; const widthFactor =
!parent || parent === riverId
? mainStemWidthFactor
: defaultWidthFactor;
const meanderedPoints = this.addMeandering(riverCells); const meanderedPoints = this.addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second const discharge = cells.fl[mouth]; // m3 in second
const length = this.getApproximateLength(meanderedPoints); const length = this.getApproximateLength(meanderedPoints);
@ -219,8 +244,8 @@ class RiverModule {
flux: discharge, flux: discharge,
pointIndex: meanderedPoints.length, pointIndex: meanderedPoints.length,
widthFactor, widthFactor,
startingWidth: sourceWidth startingWidth: sourceWidth,
}) }),
); );
pack.rivers.push({ pack.rivers.push({
@ -233,10 +258,10 @@ class RiverModule {
widthFactor, widthFactor,
sourceWidth, sourceWidth,
parent, parent,
cells: riverCells cells: riverCells,
} as River); } as River);
} }
} };
const downcutRivers = () => { const downcutRivers = () => {
const MAX_DOWNCUT = 5; const MAX_DOWNCUT = 5;
@ -245,14 +270,18 @@ class RiverModule {
if (cells.h[i] < 35) continue; // don't donwcut lowlands if (cells.h[i] < 35) continue; // don't donwcut lowlands
if (!cells.fl[i]) continue; if (!cells.fl[i]) continue;
const higherCells = cells.c[i].filter((c: number) => cells.h[c] > cells.h[i]); const higherCells = cells.c[i].filter(
const higherFlux = higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / higherCells.length; (c: number) => cells.h[c] > cells.h[i],
);
const higherFlux =
higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) /
higherCells.length;
if (!higherFlux) continue; if (!higherFlux) continue;
const downcut = Math.floor(cells.fl[i] / higherFlux); const downcut = Math.floor(cells.fl[i] / higherFlux);
if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT); if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT);
} }
} };
const calculateConfluenceFlux = () => { const calculateConfluenceFlux = () => {
for (const i of cells.i) { for (const i of cells.i) {
@ -262,9 +291,13 @@ class RiverModule {
.filter((c: number) => cells.r[c] && h[c] > h[i]) .filter((c: number) => cells.r[c] && h[c] > h[i])
.map((c: number) => cells.fl[c]) .map((c: number) => cells.fl[c])
.sort((a: number, b: number) => b - a); .sort((a: number, b: number) => b - a);
cells.conf[i] = sortedInflux.reduce((acc: number, flux: number, index: number) => (index ? acc + flux : acc), 0); cells.conf[i] = sortedInflux.reduce(
} (acc: number, flux: number, index: number) =>
index ? acc + flux : acc,
0,
);
} }
};
cells.fl = new Uint16Array(cells.i.length); // water flux array cells.fl = new Uint16Array(cells.i.length); // water flux array
cells.r = new Uint16Array(cells.i.length); // rivers array cells.r = new Uint16Array(cells.i.length); // rivers array
@ -286,20 +319,28 @@ class RiverModule {
} }
TIME && console.timeEnd("generateRivers"); TIME && console.timeEnd("generateRivers");
}; }
alterHeights(): number[] { alterHeights(): number[] {
const {h, c, t} = pack.cells as {h: Uint8Array, c: number[][], t: Uint8Array}; const { h, c, t } = pack.cells as {
h: Uint8Array;
c: number[][];
t: Uint8Array;
};
return Array.from(h).map((h, i) => { return Array.from(h).map((h, i) => {
if (h < 20 || t[i] < 1) return h; if (h < 20 || t[i] < 1) return h;
return h + t[i] / 100 + (mean(c[i].map(c => t[c])) as number) / 10000; return h + t[i] / 100 + (mean(c[i].map((c) => t[c])) as number) / 10000;
}); });
}; }
// depression filling algorithm (for a correct water flux modeling) // depression filling algorithm (for a correct water flux modeling)
resolveDepressions(h: number[]) { resolveDepressions(h: number[]) {
const {cells, features} = pack; const { cells, features } = pack;
const maxIterations = +(document.getElementById("resolveDepressionsStepsOutput") as HTMLInputElement)?.value; const maxIterations = +(
document.getElementById(
"resolveDepressionsStepsOutput",
) as HTMLInputElement
)?.value;
const checkLakeMaxIteration = maxIterations * 0.85; const checkLakeMaxIteration = maxIterations * 0.85;
const elevateLakeMaxIteration = maxIterations * 0.75; const elevateLakeMaxIteration = maxIterations * 0.75;
@ -312,7 +353,11 @@ class RiverModule {
const progress = []; const progress = [];
let depressions = Infinity; let depressions = Infinity;
let prevDepressions = null; let prevDepressions = null;
for (let iteration = 0; depressions && iteration < maxIterations; iteration++) { for (
let iteration = 0;
depressions && iteration < maxIterations;
iteration++
) {
if (progress.length > 5 && sum(progress) > 0) { if (progress.length > 5 && sum(progress) > 0) {
// bad progress, abort and set heights back // bad progress, abort and set heights back
h = this.alterHeights(); h = this.alterHeights();
@ -329,8 +374,11 @@ class RiverModule {
if (minHeight >= 100 || l.height > minHeight) continue; if (minHeight >= 100 || l.height > minHeight) continue;
if (iteration > elevateLakeMaxIteration) { if (iteration > elevateLakeMaxIteration) {
l.shoreline.forEach((i: number) => (h[i] = cells.h[i])); l.shoreline.forEach((i: number) => {
l.height = (min(l.shoreline.map((s: number) => h[s])) as number) - 1; h[i] = cells.h[i];
});
l.height =
(min(l.shoreline.map((s: number) => h[s])) as number) - 1;
l.closed = true; l.closed = true;
continue; continue;
} }
@ -341,7 +389,9 @@ class RiverModule {
} }
for (const i of land) { for (const i of land) {
const minHeight = min(cells.c[i].map((c: number) => height(c))) as number; const minHeight = min(
cells.c[i].map((c: number) => height(c)),
) as number;
if (minHeight >= 100 || h[i] > minHeight) continue; if (minHeight >= 100 || h[i] > minHeight) continue;
depressions++; depressions++;
@ -352,11 +402,19 @@ class RiverModule {
prevDepressions = depressions; prevDepressions = depressions;
} }
depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); depressions &&
}; WARN &&
console.warn(
`Unresolved depressions: ${depressions}. Edit heightmap to fix`,
);
}
addMeandering(riverCells: number[], riverPoints = null, meandering = 0.5): [number, number, number][] { addMeandering(
const {fl, h} = pack.cells; riverCells: number[],
riverPoints = null,
meandering = 0.5,
): [number, number, number][] {
const { fl, h } = pack.cells;
const meandered = []; const meandered = [];
const lastStep = riverCells.length - 1; const lastStep = riverCells.length - 1;
const points = this.getRiverPoints(riverCells, riverPoints); const points = this.getRiverPoints(riverCells, riverPoints);
@ -382,7 +440,8 @@ class RiverModule {
const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells
if (dist2 <= 25 && riverCells.length >= 6) continue; if (dist2 <= 25 && riverCells.length >= 6) continue;
const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0); const meander =
meandering + 1 / step + Math.max(meandering - step / 100, 0);
const angle = Math.atan2(y2 - y1, x2 - x1); const angle = Math.atan2(y2 - y1, x2 - x1);
const sinMeander = Math.sin(angle) * meander; const sinMeander = Math.sin(angle) * meander;
const cosMeander = Math.cos(angle) * meander; const cosMeander = Math.cos(angle) * meander;
@ -403,17 +462,17 @@ class RiverModule {
} }
return meandered as [number, number, number][]; return meandered as [number, number, number][];
}; }
getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) { getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) {
if (riverPoints) return riverPoints; if (riverPoints) return riverPoints;
const {p} = pack.cells; const { p } = pack.cells;
return riverCells.map((cell, i) => { return riverCells.map((cell, i) => {
if (cell === -1) return this.getBorderPoint(riverCells[i - 1]); if (cell === -1) return this.getBorderPoint(riverCells[i - 1]);
return p[cell]; return p[cell];
}); });
}; }
getBorderPoint(i: number) { getBorderPoint(i: number) {
const [x, y] = pack.cells.p[i]; const [x, y] = pack.cells.p[i];
@ -422,22 +481,42 @@ class RiverModule {
else if (min === graphHeight - y) return [x, graphHeight]; else if (min === graphHeight - y) return [x, graphHeight];
else if (min === x) return [0, y]; else if (min === x) return [0, y];
return [graphWidth, y]; return [graphWidth, y];
}; }
getOffset({flux, pointIndex, widthFactor, startingWidth}: {flux: number, pointIndex: number, widthFactor: number, startingWidth: number}) { getOffset({
flux,
pointIndex,
widthFactor,
startingWidth,
}: {
flux: number;
pointIndex: number;
widthFactor: number;
startingWidth: number;
}) {
if (pointIndex === 0) return startingWidth; if (pointIndex === 0) return startingWidth;
const fluxWidth = Math.min(flux ** 0.7 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH); const fluxWidth = Math.min(
const lengthWidth = pointIndex * this.LENGTH_STEP_WIDTH + (this.LENGTH_PROGRESSION[pointIndex] || this.LENGTH_PROGRESSION.at(-1) as number); flux ** 0.7 / this.FLUX_FACTOR,
this.MAX_FLUX_WIDTH,
);
const lengthWidth =
pointIndex * this.LENGTH_STEP_WIDTH +
(this.LENGTH_PROGRESSION[pointIndex] ||
(this.LENGTH_PROGRESSION.at(-1) as number));
return widthFactor * (lengthWidth + fluxWidth) + startingWidth; return widthFactor * (lengthWidth + fluxWidth) + startingWidth;
}; }
getSourceWidth(flux: number) { getSourceWidth(flux: number) {
return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2); return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2);
} }
// build polygon from a list of points and calculated offset (width) // build polygon from a list of points and calculated offset (width)
getRiverPath(points: [number, number, number][], widthFactor: number, startingWidth: number) { getRiverPath(
points: [number, number, number][],
widthFactor: number,
startingWidth: number,
) {
this.lineGen.curve(curveCatmullRom.alpha(0.1)); this.lineGen.curve(curveCatmullRom.alpha(0.1));
const riverPointsLeft: [number, number][] = []; const riverPointsLeft: [number, number][] = [];
const riverPointsRight: [number, number][] = []; const riverPointsRight: [number, number][] = [];
@ -449,7 +528,12 @@ class RiverModule {
const [x2, y2] = points[pointIndex + 1] || points[pointIndex]; const [x2, y2] = points[pointIndex + 1] || points[pointIndex];
if (pointFlux > flux) flux = pointFlux; if (pointFlux > flux) flux = pointFlux;
const offset = this.getOffset({flux, pointIndex, widthFactor, startingWidth}); const offset = this.getOffset({
flux,
pointIndex,
widthFactor,
startingWidth,
});
const angle = Math.atan2(y0 - y2, x0 - x2); const angle = Math.atan2(y0 - y2, x0 - x2);
const sinOffset = Math.sin(angle) * offset; const sinOffset = Math.sin(angle) * offset;
const cosOffset = Math.cos(angle) * offset; const cosOffset = Math.cos(angle) * offset;
@ -463,7 +547,7 @@ class RiverModule {
left = left.substring(left.indexOf("C")); left = left.substring(left.indexOf("C"));
return round(right + left, 1); return round(right + left, 1);
}; }
specify() { specify() {
const rivers = pack.rivers; const rivers = pack.rivers;
@ -474,57 +558,69 @@ class RiverModule {
river.name = this.getName(river.mouth); river.name = this.getName(river.mouth);
river.type = this.getType(river); river.type = this.getType(river);
} }
}; }
getName(cell: number) { getName(cell: number) {
return Names.getCulture(pack.cells.culture[cell]); return Names.getCulture(pack.cells.culture[cell]);
}; }
getType({i, length, parent}: River) { getType({ i, length, parent }: River) {
if (this.smallLength === null) { if (this.smallLength === null) {
const threshold = Math.ceil(pack.rivers.length * 0.15); const threshold = Math.ceil(pack.rivers.length * 0.15);
this.smallLength = pack.rivers.map(r => r.length || 0).sort((a: number, b: number) => a - b)[threshold]; this.smallLength = pack.rivers
.map((r) => r.length || 0)
.sort((a: number, b: number) => a - b)[threshold];
} }
const isSmall: boolean = length < (this.smallLength as number); const isSmall: boolean = length < (this.smallLength as number);
const isFork = each(3)(i) && parent && parent !== i; const isFork = each(3)(i) && parent && parent !== i;
return rw(this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); return rw(
}; this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"],
);
}
getApproximateLength(points: [number, number, number][]) { getApproximateLength(points: [number, number, number][]) {
const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0); const length = points.reduce(
(s, v, i, p) =>
s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0),
0,
);
return rn(length, 2); return rn(length, 2);
}; }
// Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m, // Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m,
// Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m // Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m
getWidth(offset: number) { getWidth(offset: number) {
return rn((offset / 1.5) ** 1.8, 2); // mouth width in km return rn((offset / 1.5) ** 1.8, 2); // mouth width in km
}; }
// remove river and all its tributaries // remove river and all its tributaries
remove(id: number) { remove(id: number) {
const cells = pack.cells; const cells = pack.cells;
const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i); const riversToRemove = pack.rivers
riversToRemove.forEach(r => rivers.select("#river" + r).remove()); .filter((r) => r.i === id || r.parent === id || r.basin === id)
.map((r) => r.i);
riversToRemove.forEach((r) => {
rivers.select(`#river${r}`).remove();
});
cells.r.forEach((r, i) => { cells.r.forEach((r, i) => {
if (!r || !riversToRemove.includes(r)) return; if (!r || !riversToRemove.includes(r)) return;
cells.r[i] = 0; cells.r[i] = 0;
cells.fl[i] = grid.cells.prec[cells.g[i]]; cells.fl[i] = grid.cells.prec[cells.g[i]];
cells.conf[i] = 0; cells.conf[i] = 0;
}); });
pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i)); pack.rivers = pack.rivers.filter((r) => !riversToRemove.includes(r.i));
}; }
getBasin(r: number): number { getBasin(r: number): number {
const parent = pack.rivers.find(river => river.i === r)?.parent; const parent = pack.rivers.find((river) => river.i === r)?.parent;
if (!parent || r === parent) return r; if (!parent || r === parent) return r;
return this.getBasin(parent); return this.getBasin(parent);
}; }
getNextId(rivers: {i: number}[]) { getNextId(rivers: { i: number }[]) {
return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1; return rivers.length ? Math.max(...rivers.map((r) => r.i)) + 1 : 1;
}; }
} }
window.Rivers = new RiverModule() window.Rivers = new RiverModule();

View file

@ -1,6 +1,11 @@
import Delaunator from "delaunator"; import type Delaunator from "delaunator";
export type Vertices = { p: Point[], v: number[][], c: number[][] }; export type Vertices = { p: Point[]; v: number[][]; c: number[][] };
export type Cells = { v: number[][], c: number[][], b: number[], i: Uint32Array<ArrayBufferLike> } ; export type Cells = {
v: number[][];
c: number[][];
b: number[];
i: Uint32Array<ArrayBufferLike>;
};
export type Point = [number, number]; export type Point = [number, number];
/** /**
@ -11,28 +16,33 @@ export type Point = [number, number];
* @param {number} pointsN The number of points. * @param {number} pointsN The number of points.
*/ */
export class Voronoi { export class Voronoi {
delaunay: Delaunator<Float64Array<ArrayBufferLike>> delaunay: Delaunator<Float64Array<ArrayBufferLike>>;
points: Point[]; points: Point[];
pointsN: number; 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; 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 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) { constructor(
delaunay: Delaunator<Float64Array<ArrayBufferLike>>,
points: Point[],
pointsN: number,
) {
this.delaunay = delaunay; this.delaunay = delaunay;
this.points = points; this.points = points;
this.pointsN = pointsN; this.pointsN = pointsN;
this.vertices this.vertices;
// Half-edges are the indices into the delaunator outputs: // Half-edges are the indices into the delaunator outputs:
// delaunay.triangles[e] gives the point ID where the half-edge starts // 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. // 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++) { for (let e = 0; e < this.delaunay.triangles.length; e++) {
const p = this.delaunay.triangles[this.nextHalfedge(e)]; const p = this.delaunay.triangles[this.nextHalfedge(e)];
if (p < this.pointsN && !this.cells.c[p]) { if (p < this.pointsN && !this.cells.c[p]) {
const edges = this.edgesAroundPoint(e); const edges = this.edgesAroundPoint(e);
this.cells.v[p] = edges.map(e => this.triangleOfEdge(e)); // cell: adjacent vertex 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.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 this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border
} }
@ -51,7 +61,9 @@ export class Voronoi {
* @returns {[number, number, number]} The IDs of the points comprising the given triangle. * @returns {[number, number, number]} The IDs of the points comprising the given triangle.
*/ */
private pointsOfTriangle(triangleIndex: number): [number, number, number] { private pointsOfTriangle(triangleIndex: number): [number, number, number] {
return this.edgesOfTriangle(triangleIndex).map(edge => this.delaunay.triangles[edge]) as [number, number, number]; return this.edgesOfTriangle(triangleIndex).map(
(edge) => this.delaunay.triangles[edge],
) as [number, number, number];
} }
/** /**
@ -60,9 +72,9 @@ export class Voronoi {
* @returns {number[]} The indices of the triangles that share half-edges with this triangle. * @returns {number[]} The indices of the triangles that share half-edges with this triangle.
*/ */
private trianglesAdjacentToTriangle(triangleIndex: number): number[] { private trianglesAdjacentToTriangle(triangleIndex: number): number[] {
let triangles = []; const triangles = [];
for (let edge of this.edgesOfTriangle(triangleIndex)) { for (const edge of this.edgesOfTriangle(triangleIndex)) {
let opposite = this.delaunay.halfedges[edge]; const opposite = this.delaunay.halfedges[edge];
triangles.push(this.triangleOfEdge(opposite)); triangles.push(this.triangleOfEdge(opposite));
} }
return triangles; return triangles;
@ -90,7 +102,9 @@ export class Voronoi {
* @returns {[number, number]} The coordinates of the triangle's circumcenter. * @returns {[number, number]} The coordinates of the triangle's circumcenter.
*/ */
private triangleCenter(triangleIndex: number): Point { private triangleCenter(triangleIndex: number): Point {
let vertices = this.pointsOfTriangle(triangleIndex).map(p => this.points[p]); const vertices = this.pointsOfTriangle(triangleIndex).map(
(p) => this.points[p],
);
return this.circumcenter(vertices[0], vertices[1], vertices[2]); return this.circumcenter(vertices[0], vertices[1], vertices[2]);
} }
@ -99,21 +113,27 @@ export class Voronoi {
* @param {number} triangleIndex The index of the triangle * @param {number} triangleIndex The index of the triangle
* @returns {[number, number, number]} The edges 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]; } 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.} * 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 * @param {number} e The index of the edge
* @returns {number} The index of the triangle * @returns {number} The index of the triangle
*/ */
private triangleOfEdge(e: number): number { return Math.floor(e / 3); } 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.} * 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 * @param {number} e The index of the current half edge
* @returns {number} The index of the next half edge * @returns {number} The index of the next half edge
*/ */
private nextHalfedge(e: number): number { return (e % 3 === 2) ? e - 2 : e + 1; } 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.} * 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.}
@ -138,8 +158,8 @@ export class Voronoi {
const cd = cx * cx + cy * cy; const cd = cx * cx + cy * cy;
const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
return [ return [
Math.floor(1 / D * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), 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))) Math.floor((1 / D) * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))),
]; ];
} }
} }

View file

@ -1,8 +1,14 @@
import type { PackedGraphFeature } from "../modules/features"; import type { PackedGraphFeature } from "../modules/features";
import type { River } from "../modules/river-generator"; import type { River } from "../modules/river-generator";
type TypedArray =
type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array; | Uint8Array
| Uint16Array
| Uint32Array
| Int8Array
| Int16Array
| Float32Array
| Float64Array;
export interface PackedGraph { export interface PackedGraph {
cells: { cells: {
@ -33,5 +39,4 @@ export interface PackedGraph {
}; };
rivers: River[]; rivers: River[];
features: PackedGraphFeature[]; features: PackedGraphFeature[];
cultures: any[];
} }

View file

@ -1,6 +1,6 @@
import type { Selection } from 'd3'; import type { Selection } from 'd3';
import { PackedGraph } from "./PackedGraph"; import type { PackedGraph } from "./PackedGraph";
import { NameBase } from '../modules/names-generator'; import type { NameBase } from '../modules/names-generator';
declare global { declare global {
var seed: string; var seed: string;
@ -8,7 +8,6 @@ declare global {
var grid: any; var grid: any;
var graphHeight: number; var graphHeight: number;
var graphWidth: number; var graphWidth: number;
var TIME: boolean; var TIME: boolean;
var WARN: boolean; var WARN: boolean;
var ERROR: boolean; var ERROR: boolean;
@ -16,6 +15,7 @@ declare global {
var heightmapTemplates: any; var heightmapTemplates: any;
var nameBases: NameBase[]; var nameBases: NameBase[];
var Names: any;
var pointsInput: HTMLInputElement; var pointsInput: HTMLInputElement;
var heightExponentInput: HTMLInputElement; var heightExponentInput: HTMLInputElement;
var mapName: HTMLInputElement; var mapName: HTMLInputElement;

View file

@ -5,7 +5,7 @@
*/ */
export const last = <T>(array: T[]): T => { export const last = <T>(array: T[]): T => {
return array[array.length - 1]; return array[array.length - 1];
} };
/** /**
* Get unique elements from an array * Get unique elements from an array
@ -14,7 +14,7 @@ export const last = <T>(array: T[]): T => {
*/ */
export const unique = <T>(array: T[]): T[] => { export const unique = <T>(array: T[]): T[] => {
return [...new Set(array)]; return [...new Set(array)];
} };
/** /**
* Deep copy an object or array * Deep copy an object or array
@ -24,12 +24,15 @@ export const unique = <T>(array: T[]): T[] => {
export const deepCopy = <T>(obj: T): T => { export const deepCopy = <T>(obj: T): T => {
const id = (x: T): T => x; const id = (x: T): T => x;
const dcTArray = (a: T[]): T[] => a.map(id); 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 dcObject = (x: object): object =>
const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x); 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 // 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 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>([ const cf: Map<any, (x: any) => any> = new Map<any, (x: any) => any>([
[Int8Array, dcTArray], [Int8Array, dcTArray],
[Uint8Array, dcTArray], [Uint8Array, dcTArray],
[Uint8ClampedArray, dcTArray], [Uint8ClampedArray, dcTArray],
@ -41,17 +44,17 @@ export const deepCopy = <T>(obj: T): T => {
[Float64Array, dcTArray], [Float64Array, dcTArray],
[BigInt64Array, dcTArray], [BigInt64Array, dcTArray],
[BigUint64Array, dcTArray], [BigUint64Array, dcTArray],
[Map, m => new Map(dcMapCore(m))], [Map, (m) => new Map(dcMapCore(m))],
[WeakMap, m => new WeakMap(dcMapCore(m))], [WeakMap, (m) => new WeakMap(dcMapCore(m))],
[Array, a => a.map(dcAny)], [Array, (a) => a.map(dcAny)],
[Set, s => [...s.values()].map(dcAny)], [Set, (s) => [...s.values()].map(dcAny)],
[Date, d => new Date(d.getTime())], [Date, (d) => new Date(d.getTime())],
[Object, dcObject] [Object, dcObject],
// ... extend here to implement their custom deep copy // ... extend here to implement their custom deep copy
]); ]);
return dcAny(obj); return dcAny(obj);
} };
/** /**
* Get the appropriate typed array constructor based on the maximum value * Get the appropriate typed array constructor based on the maximum value
@ -60,15 +63,17 @@ export const deepCopy = <T>(obj: T): T => {
*/ */
export const getTypedArray = (maxValue: number) => { export const getTypedArray = (maxValue: number) => {
console.assert( console.assert(
Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, Number.isInteger(maxValue) &&
`Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${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.UINT8_MAX) return Uint8Array;
if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array;
if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array;
return Uint32Array; return Uint32Array;
} };
/** /**
* Create a typed array based on the maximum value and length or from an existing array * Create a typed array based on the maximum value and length or from an existing array
@ -78,18 +83,26 @@ export const getTypedArray = (maxValue: number) => {
* @param {Array} [options.from] - An optional array to create the typed array from * @param {Array} [options.from] - An optional array to create the typed array from
* @returns The created typed array * @returns The created typed array
*/ */
export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike<number>}): Uint8Array | Uint16Array | Uint32Array => { export const createTypedArray = ({
maxValue,
length,
from,
}: {
maxValue: number;
length: number;
from?: ArrayLike<number>;
}): Uint8Array | Uint16Array | Uint32Array => {
const typedArray = getTypedArray(maxValue); const typedArray = getTypedArray(maxValue);
if (!from) return new typedArray(length); if (!from) return new typedArray(length);
return typedArray.from(from); return typedArray.from(from);
} };
// typed arrays max values // typed arrays max values
export const TYPED_ARRAY_MAX_VALUES = { export const TYPED_ARRAY_MAX_VALUES = {
INT8_MAX: 127, INT8_MAX: 127,
UINT8_MAX: 255, UINT8_MAX: 255,
UINT16_MAX: 65535, UINT16_MAX: 65535,
UINT32_MAX: 4294967295 UINT32_MAX: 4294967295,
}; };
declare global { declare global {

View file

@ -1,4 +1,12 @@
import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffler } from "d3"; import {
color,
interpolate,
interpolateRainbow,
type RGBColor,
range,
scaleSequential,
shuffler,
} from "d3";
/** /**
* Convert RGB or RGBA color to HEX * Convert RGB or RGBA color to HEX
@ -8,14 +16,16 @@ import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequentia
export const toHEX = (rgba: string): string => { export const toHEX = (rgba: string): string => {
if (rgba.charAt(0) === "#") return rgba; if (rgba.charAt(0) === "#") return rgba;
const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); const matches = rgba.match(
/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i,
);
return matches && matches.length === 4 return matches && matches.length === 4
? "#" + ? "#" +
("0" + parseInt(matches[1], 10).toString(16)).slice(-2) + `0${parseInt(matches[1], 10).toString(16)}`.slice(-2) +
("0" + parseInt(matches[2], 10).toString(16)).slice(-2) + `0${parseInt(matches[2], 10).toString(16)}`.slice(-2) +
("0" + parseInt(matches[3], 10).toString(16)).slice(-2) `0${parseInt(matches[3], 10).toString(16)}`.slice(-2)
: ""; : "";
} };
/** Predefined set of 12 distinct colors */ /** Predefined set of 12 distinct colors */
export const C_12 = [ export const C_12 = [
@ -30,7 +40,7 @@ export const C_12 = [
"#ccebc5", "#ccebc5",
"#ffed6f", "#ffed6f",
"#8dd3c7", "#8dd3c7",
"#eb8de7" "#eb8de7",
]; ];
/** /**
@ -38,25 +48,31 @@ export const C_12 = [
* Uses shuffler with current Math.random to ensure seeded randomness works * Uses shuffler with current Math.random to ensure seeded randomness works
* @param {number} count - The count of colors to generate * @param {number} count - The count of colors to generate
* @returns {string[]} - The array of HEX color strings * @returns {string[]} - The array of HEX color strings
*/ */
export const getColors = (count: number): string[] => { export const getColors = (count: number): string[] => {
const scaleRainbow = scaleSequential(interpolateRainbow); const scaleRainbow = scaleSequential(interpolateRainbow);
// Use shuffler() to create a shuffle function that uses the current Math.random // Use shuffler() to create a shuffle function that uses the current Math.random
const shuffle = shuffler(() => Math.random()); const shuffle = shuffler(() => Math.random());
const colors = shuffle( const colors = shuffle(
range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex())) 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"); return colors.filter((c): c is string => typeof c === "string");
} };
/** /**
* Get a random color in HEX format * Get a random color in HEX format
* @returns {string} - The HEX color string * @returns {string} - The HEX color string
*/ */
export const getRandomColor = (): string => { export const getRandomColor = (): string => {
const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor; const colorFromRainbow: RGBColor = color(
scaleSequential(interpolateRainbow)(Math.random()),
) as RGBColor;
return colorFromRainbow.formatHex(); return colorFromRainbow.formatHex();
} };
/** /**
* Get a mixed color by blending a given color with a random color * Get a mixed color by blending a given color with a random color
@ -65,11 +81,17 @@ export const getRandomColor = (): string => {
* @param {number} bright - The brightness adjustment * @param {number} bright - The brightness adjustment
* @returns {string} - The mixed HEX color string * @returns {string} - The mixed HEX color string
*/ */
export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): 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 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; const mixedColor: RGBColor = color(
interpolate(c, getRandomColor())(mix),
) as RGBColor;
return mixedColor.brighter(bright).formatHex(); return mixedColor.brighter(bright).formatHex();
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { getCoordinates, getLatitude, getLongitude } from "./commonUtils";
describe("getLongitude", () => {
const mapCoordinates = { lonW: -10, lonT: 20 };
const graphWidth = 1000;
it("should calculate longitude at the left edge (x=0)", () => {
expect(getLongitude(0, mapCoordinates, graphWidth, 2)).toBe(-10);
});
it("should calculate longitude at the right edge (x=graphWidth)", () => {
expect(getLongitude(1000, mapCoordinates, graphWidth, 2)).toBe(10);
});
it("should calculate longitude at the center (x=graphWidth/2)", () => {
expect(getLongitude(500, mapCoordinates, graphWidth, 2)).toBe(0);
});
it("should respect decimal precision", () => {
// 333/1000 * 20 = 6.66, -10 + 6.66 = -3.34
expect(getLongitude(333, mapCoordinates, graphWidth, 4)).toBe(-3.34);
});
it("should handle different map coordinate ranges", () => {
const wideMap = { lonW: -180, lonT: 360 };
expect(getLongitude(500, wideMap, graphWidth, 2)).toBe(0);
expect(getLongitude(0, wideMap, graphWidth, 2)).toBe(-180);
expect(getLongitude(1000, wideMap, graphWidth, 2)).toBe(180);
});
});
describe("getLatitude", () => {
const mapCoordinates = { latN: 60, latT: 40 };
const graphHeight = 800;
it("should calculate latitude at the top edge (y=0)", () => {
expect(getLatitude(0, mapCoordinates, graphHeight, 2)).toBe(60);
});
it("should calculate latitude at the bottom edge (y=graphHeight)", () => {
expect(getLatitude(800, mapCoordinates, graphHeight, 2)).toBe(20);
});
it("should calculate latitude at the center (y=graphHeight/2)", () => {
expect(getLatitude(400, mapCoordinates, graphHeight, 2)).toBe(40);
});
it("should respect decimal precision", () => {
// 60 - (333/800 * 40) = 60 - 16.65 = 43.35
expect(getLatitude(333, mapCoordinates, graphHeight, 4)).toBe(43.35);
});
it("should handle equator-centered maps", () => {
const equatorMap = { latN: 45, latT: 90 };
expect(getLatitude(400, equatorMap, graphHeight, 2)).toBe(0);
});
});
describe("getCoordinates", () => {
const mapCoordinates = { lonW: -10, lonT: 20, latN: 60, latT: 40 };
const graphWidth = 1000;
const graphHeight = 800;
it("should return [longitude, latitude] tuple", () => {
const result = getCoordinates(
500,
400,
mapCoordinates,
graphWidth,
graphHeight,
2,
);
expect(result).toEqual([0, 40]);
});
it("should calculate coordinates at top-left corner", () => {
const result = getCoordinates(
0,
0,
mapCoordinates,
graphWidth,
graphHeight,
2,
);
expect(result).toEqual([-10, 60]);
});
it("should calculate coordinates at bottom-right corner", () => {
const result = getCoordinates(
1000,
800,
mapCoordinates,
graphWidth,
graphHeight,
2,
);
expect(result).toEqual([10, 20]);
});
it("should respect decimal precision for both coordinates", () => {
const result = getCoordinates(
333,
333,
mapCoordinates,
graphWidth,
graphHeight,
4,
);
expect(result[0]).toBe(-3.34); // longitude
expect(result[1]).toBe(43.35); // latitude
});
it("should use default precision of 2 decimals", () => {
const result = getCoordinates(
333,
333,
mapCoordinates,
graphWidth,
graphHeight,
);
expect(result[0]).toBe(-3.34);
expect(result[1]).toBe(43.35);
});
it("should handle global map coordinates", () => {
const globalMap = { lonW: -180, lonT: 360, latN: 90, latT: 180 };
const result = getCoordinates(
500,
400,
globalMap,
graphWidth,
graphHeight,
2,
);
expect(result).toEqual([0, 0]); // center of the world
});
});

View file

@ -1,7 +1,7 @@
import { distanceSquared } from "./functionUtils";
import { rand } from "./probabilityUtils";
import { rn } from "./numberUtils";
import { last } from "./arrayUtils"; import { last } from "./arrayUtils";
import { distanceSquared } from "./functionUtils";
import { rn } from "./numberUtils";
import { rand } from "./probabilityUtils";
/** /**
* Clip polygon points to graph boundaries * Clip polygon points to graph boundaries
@ -11,15 +11,20 @@ import { last } from "./arrayUtils";
* @param secure - Secure clipping to avoid edge artifacts * @param secure - Secure clipping to avoid edge artifacts
* @returns Clipped polygon points * @returns Clipped polygon points
*/ */
export const clipPoly = (points: [number, number][], graphWidth?: number, graphHeight?: number, secure: number = 0) => { export const clipPoly = (
points: [number, number][],
graphWidth?: number,
graphHeight?: number,
secure: number = 0,
) => {
if (points.length < 2) return points; if (points.length < 2) return points;
if (points.some(point => point === undefined)) { if (points.some((point) => point === undefined)) {
window.ERROR && console.error("Undefined point in clipPoly", points); window.ERROR && console.error("Undefined point in clipPoly", points);
return points; return points;
} }
return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure); return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
} };
/** /**
* Get segment of any point on polyline * Get segment of any point on polyline
@ -28,7 +33,11 @@ export const clipPoly = (points: [number, number][], graphWidth?: number, graphH
* @param step - Step size for segment search (default is 10) * @param step - Step size for segment search (default is 10)
* @returns The segment ID (1-indexed) * @returns The segment ID (1-indexed)
*/ */
export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => { export const getSegmentId = (
points: [number, number][],
point: [number, number],
step: number = 10,
): number => {
if (points.length === 2) return 1; if (points.length === 2) return 1;
let minSegment = 1; let minSegment = 1;
@ -55,7 +64,7 @@ export const getSegmentId = (points: [number, number][], point: [number, number]
} }
return minSegment; return minSegment;
} };
/** /**
* Creates a debounced function that delays invoking func until after ms milliseconds have elapsed * Creates a debounced function that delays invoking func until after ms milliseconds have elapsed
@ -63,16 +72,21 @@ export const getSegmentId = (points: [number, number][], point: [number, number]
* @param ms - The number of milliseconds to delay * @param ms - The number of milliseconds to delay
* @returns The debounced function * @returns The debounced function
*/ */
export const debounce = <T extends (...args: any[]) => any>(func: T, ms: number) => { export const debounce = <T extends (...args: any[]) => any>(
func: T,
ms: number,
) => {
let isCooldown = false; let isCooldown = false;
return function (this: any, ...args: Parameters<T>) { return function (this: any, ...args: Parameters<T>) {
if (isCooldown) return; if (isCooldown) return;
func.apply(this, args); func.apply(this, args);
isCooldown = true; isCooldown = true;
setTimeout(() => (isCooldown = false), ms); setTimeout(() => {
isCooldown = false;
}, ms);
}; };
} };
/** /**
* Creates a throttled function that only invokes func at most once every ms milliseconds * Creates a throttled function that only invokes func at most once every ms milliseconds
@ -80,7 +94,10 @@ export const debounce = <T extends (...args: any[]) => any>(func: T, ms: number)
* @param ms - The number of milliseconds to throttle invocations to * @param ms - The number of milliseconds to throttle invocations to
* @returns The throttled function * @returns The throttled function
*/ */
export const throttle = <T extends (...args: any[]) => any>(func: T, ms: number) => { export const throttle = <T extends (...args: any[]) => any>(
func: T,
ms: number,
) => {
let isThrottled = false; let isThrottled = false;
let savedArgs: any[] | null = null; let savedArgs: any[] | null = null;
let savedThis: any = null; let savedThis: any = null;
@ -95,7 +112,7 @@ export const throttle = <T extends (...args: any[]) => any>(func: T, ms: number)
func.apply(this, args); func.apply(this, args);
isThrottled = true; isThrottled = true;
setTimeout(function () { setTimeout(() => {
isThrottled = false; isThrottled = false;
if (savedArgs) { if (savedArgs) {
wrapper.apply(savedThis, savedArgs as Parameters<T>); wrapper.apply(savedThis, savedArgs as Parameters<T>);
@ -105,7 +122,7 @@ export const throttle = <T extends (...args: any[]) => any>(func: T, ms: number)
} }
return wrapper; return wrapper;
} };
/** /**
* Parse error to get the readable string in Chrome and Firefox * Parse error to get the readable string in Chrome and Firefox
@ -114,23 +131,32 @@ export const throttle = <T extends (...args: any[]) => any>(func: T, ms: number)
*/ */
export const parseError = (error: Error): string => { export const parseError = (error: Error): string => {
const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack || ""; const errorString = isFirefox
const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; ? `${error.toString()} ${error.stack}`
const errorNoURL = errorString.replace(regex, url => "<i>" + last(url.split("/")) + "</i>"); : 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 "); const errorParsed = errorNoURL.replace(/at /gi, "<br>&nbsp;&nbsp;at ");
return errorParsed; return errorParsed;
} };
/** /**
* Convert a URL to base64 encoded data * Convert a URL to base64 encoded data
* @param url - The URL to convert * @param url - The URL to convert
* @param callback - Callback function that receives the base64 data * @param callback - Callback function that receives the base64 data
*/ */
export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | null) => void): void => { export const getBase64 = (
url: string,
callback: (result: string | ArrayBuffer | null) => void,
): void => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.onload = function () { xhr.onload = () => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = function () { reader.onloadend = () => {
callback(reader.result); callback(reader.result);
}; };
reader.readAsDataURL(xhr.response); reader.readAsDataURL(xhr.response);
@ -138,7 +164,7 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer |
xhr.open("GET", url); xhr.open("GET", url);
xhr.responseType = "blob"; xhr.responseType = "blob";
xhr.send(); xhr.send();
} };
/** /**
* Open URL in a new tab or window * Open URL in a new tab or window
@ -146,15 +172,18 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer |
*/ */
export const openURL = (url: string): void => { export const openURL = (url: string): void => {
window.open(url, "_blank"); window.open(url, "_blank");
} };
/** /**
* Open project wiki-page * Open project wiki-page
* @param page - The wiki page name/path to open * @param page - The wiki page name/path to open
*/ */
export const wiki = (page: string): void => { export const wiki = (page: string): void => {
window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank"); window.open(
} `https://github.com/Azgaar/Fantasy-Map-Generator/wiki/${page}`,
"_blank",
);
};
/** /**
* Wrap URL into html a element * Wrap URL into html a element
@ -164,7 +193,7 @@ export const wiki = (page: string): void => {
*/ */
export const link = (URL: string, description: string): string => { export const link = (URL: string, description: string): string => {
return `<a href="${URL}" rel="noopener" target="_blank">${description}</a>`; return `<a href="${URL}" rel="noopener" target="_blank">${description}</a>`;
} };
/** /**
* Check if Ctrl key (or Cmd on Mac) was pressed during an event * Check if Ctrl key (or Cmd on Mac) was pressed during an event
@ -174,7 +203,7 @@ export const link = (URL: string, description: string): string => {
export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => { export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => {
// meta key is cmd key on MacOs // meta key is cmd key on MacOs
return event.ctrlKey || event.metaKey; return event.ctrlKey || event.metaKey;
} };
/** /**
* Generate a random date within a specified range * Generate a random date within a specified range
@ -186,9 +215,9 @@ export const generateDate = (from: number = 100, to: number = 1000): string => {
return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", { return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric" day: "numeric",
}); });
} };
/** /**
* Convert x coordinate to longitude * Convert x coordinate to longitude
@ -198,9 +227,17 @@ export const generateDate = (from: number = 100, to: number = 1000): string => {
* @param decimals - Number of decimal places (default is 2) * @param decimals - Number of decimal places (default is 2)
* @returns Longitude value * @returns Longitude value
*/ */
export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => { export const getLongitude = (
return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals); x: number,
} mapCoordinates: any,
graphWidth: number,
decimals: number = 2,
): number => {
return rn(
mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT,
decimals,
);
};
/** /**
* Convert y coordinate to latitude * Convert y coordinate to latitude
@ -210,9 +247,17 @@ export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number,
* @param decimals - Number of decimal places (default is 2) * @param decimals - Number of decimal places (default is 2)
* @returns Latitude value * @returns Latitude value
*/ */
export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => { export const getLatitude = (
return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals); 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 * Convert x,y coordinates to longitude,latitude
@ -224,9 +269,19 @@ export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number,
* @param decimals - Number of decimal places (default is 2) * @param decimals - Number of decimal places (default is 2)
* @returns Array with [longitude, latitude] * @returns Array with [longitude, latitude]
*/ */
export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => { export const getCoordinates = (
return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)]; 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 * Prompt options interface
@ -251,14 +306,31 @@ export const initializePrompt = (): void => {
if (!form) return; if (!form) return;
const defaultText = "Please provide an input"; const defaultText = "Please provide an input";
const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true}; 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) { (window as any).prompt = (
promptText: string = defaultText,
options: PromptOptions = defaultOptions,
callback?: (value: number | string) => void,
) => {
if (options.default === undefined) if (options.default === undefined)
return window.ERROR && console.error("Prompt: options object does not have default value defined"); return (
window.ERROR &&
console.error(
"Prompt: options object does not have default value defined",
)
);
const input = prompt.querySelector("#promptInput") as HTMLInputElement; const input = prompt.querySelector("#promptInput") as HTMLInputElement;
const promptTextElement = prompt.querySelector("#promptText") as HTMLElement; const promptTextElement = prompt.querySelector(
"#promptText",
) as HTMLElement;
if (!input || !promptTextElement) return; if (!input || !promptTextElement) return;
@ -271,8 +343,8 @@ export const initializePrompt = (): void => {
if (options.min !== undefined) input.min = options.min.toString(); if (options.min !== undefined) input.min = options.min.toString();
if (options.max !== undefined) input.max = options.max.toString(); if (options.max !== undefined) input.max = options.max.toString();
input.required = options.required === false ? false : true; input.required = options.required !== false;
input.placeholder = "type a " + type; input.placeholder = `type a ${type}`;
input.value = options.default.toString(); input.value = options.default.toString();
input.style.width = promptText.length > 10 ? "100%" : "auto"; input.style.width = promptText.length > 10 ? "100%" : "auto";
prompt.style.display = "block"; prompt.style.display = "block";
@ -285,7 +357,7 @@ export const initializePrompt = (): void => {
const v = type === "number" ? +input.value : input.value; const v = type === "number" ? +input.value : input.value;
if (callback) callback(v); if (callback) callback(v);
}, },
{once: true} { once: true },
); );
}; };
@ -295,7 +367,7 @@ export const initializePrompt = (): void => {
prompt.style.display = "none"; prompt.style.display = "none";
}); });
} }
} };
declare global { declare global {
interface Window { interface Window {
@ -317,4 +389,16 @@ declare global {
getLatitude: typeof getLatitude; getLatitude: typeof getLatitude;
getCoordinates: typeof getCoordinates; getCoordinates: typeof getCoordinates;
} }
// Global variables defined in main.js
var mapCoordinates: {
latT?: number;
latN?: number;
latS?: number;
lonT?: number;
lonW?: number;
lonE?: number;
};
var graphWidth: number;
var graphHeight: number;
} }

View file

@ -1,7 +1,7 @@
import {curveBundle, line, max, min} from "d3"; import { curveBundle, line, max, min } from "d3";
import { normalize } from "./numberUtils";
import { getGridPolygon } from "./graphUtils";
import { C_12 } from "./colorUtils"; import { C_12 } from "./colorUtils";
import { getGridPolygon } from "./graphUtils";
import { normalize } from "./numberUtils";
import { round } from "./stringUtils"; import { round } from "./stringUtils";
/** /**
@ -19,7 +19,7 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => {
.attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0]) .attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0])
.attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1]) .attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1])
.text((d: any) => d); .text((d: any) => d);
} };
/** /**
* Drawing polygons colored according to data values for debugging purposes * Drawing polygons colored according to data values for debugging purposes
* @param {number[]} data - Array of numerical values corresponding to each cell * @param {number[]} data - Array of numerical values corresponding to each cell
@ -28,9 +28,11 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => {
export const drawPolygons = (data: number[], terrs: any, grid: any): void => { export const drawPolygons = (data: number[], terrs: any, grid: any): void => {
const maximum: number = max(data) as number; const maximum: number = max(data) as number;
const minimum: number = min(data) as number; const minimum: number = min(data) as number;
const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme")); const scheme = window.getColorScheme(
terrs.select("#landHeights").attr("scheme"),
);
data = data.map(d => 1 - normalize(d, minimum, maximum)); data = data.map((d) => 1 - normalize(d, minimum, maximum));
window.debug.selectAll("polygon").remove(); window.debug.selectAll("polygon").remove();
window.debug window.debug
.selectAll("polygon") .selectAll("polygon")
@ -40,7 +42,7 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => {
.attr("points", (_d: number, i: number) => getGridPolygon(i, grid)) .attr("points", (_d: number, i: number) => getGridPolygon(i, grid))
.attr("fill", (d: number) => scheme(d)) .attr("fill", (d: number) => scheme(d))
.attr("stroke", (d: number) => scheme(d)); .attr("stroke", (d: number) => scheme(d));
} };
/** /**
* Drawing route connections for debugging purposes * Drawing route connections for debugging purposes
@ -48,7 +50,10 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => {
*/ */
export const drawRouteConnections = (packedGraph: any): void => { export const drawRouteConnections = (packedGraph: any): void => {
window.debug.select("#connections").remove(); window.debug.select("#connections").remove();
const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8); const routes = window.debug
.append("g")
.attr("id", "connections")
.attr("stroke-width", 0.8);
const points = packedGraph.cells.p; const points = packedGraph.cells.p;
const links = packedGraph.cells.routes; const links = packedGraph.cells.routes;
@ -70,7 +75,7 @@ export const drawRouteConnections = (packedGraph: any): void => {
.attr("stroke", C_12[routeId % 12]); .attr("stroke", C_12[routeId % 12]);
} }
} }
} };
/** /**
* Drawing a point for debugging purposes * Drawing a point for debugging purposes
@ -79,9 +84,17 @@ export const drawRouteConnections = (packedGraph: any): void => {
* @param {string} options.color - Color of the point * @param {string} options.color - Color of the point
* @param {number} options.radius - Radius of the point * @param {number} options.radius - Radius of the point
*/ */
export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => { export const drawPoint = (
window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color); [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 * Drawing a path for debugging purposes
@ -90,7 +103,10 @@ export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5
* @param {string} options.color - Color of the path * @param {string} options.color - Color of the path
* @param {number} options.width - Stroke width of the path * @param {number} options.width - Stroke width of the path
*/ */
export const drawPath = (points: [number, number][], {color = "red", width = 0.5}): void => { export const drawPath = (
points: [number, number][],
{ color = "red", width = 0.5 },
): void => {
const lineGen = line().curve(curveBundle); const lineGen = line().curve(curveBundle);
window.debug window.debug
.append("path") .append("path")
@ -98,7 +114,7 @@ export const drawPath = (points: [number, number][], {color = "red", width = 0.5
.attr("stroke", color) .attr("stroke", color)
.attr("stroke-width", width) .attr("stroke-width", width)
.attr("fill", "none"); .attr("fill", "none");
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -24,11 +24,20 @@
* // 'B' => Map { 'X' => 30, 'Y' => 40 } * // 'B' => Map { 'X' => 30, 'Y' => 40 }
* // } * // }
*/ */
export const rollups = (values: any[], reduce: (values: any[]) => any, ...keys: ((value: any, index: number, array: any[]) => any)[]) => { export const rollups = (
values: any[],
reduce: (values: any[]) => any,
...keys: ((value: any, index: number, array: any[]) => any)[]
) => {
return nest(values, Array.from, reduce, keys); 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)[]) => { 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) { return (function regroup(values, i) {
if (i >= keys.length) return reduce(values); if (i >= keys.length) return reduce(values);
const groups = new Map(); const groups = new Map();
@ -45,7 +54,7 @@ const nest = (values: any[], map: (iterable: Iterable<any>) => any, reduce: (val
} }
return map(groups); return map(groups);
})(values, 0); })(values, 0);
} };
/** /**
* Calculate squared distance between two points * Calculate squared distance between two points
@ -53,9 +62,12 @@ const nest = (values: any[], map: (iterable: Iterable<any>) => any, reduce: (val
* @param {[number, number]} p2 - Second point [x2, y2] * @param {[number, number]} p2 - Second point [x2, y2]
* @returns {number} - Squared distance between p1 and p2 * @returns {number} - Squared distance between p1 and p2
*/ */
export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => { export const distanceSquared = (
[x1, y1]: [number, number],
[x2, y2]: [number, number],
) => {
return (x1 - x2) ** 2 + (y1 - y2) ** 2; return (x1 - x2) ** 2 + (y1 - y2) ** 2;
} };
declare global { declare global {
interface Window { interface Window {
rollups: typeof rollups; rollups: typeof rollups;

View file

@ -1,10 +1,15 @@
import Delaunator from "delaunator";
import Alea from "alea"; import Alea from "alea";
import { color } from "d3"; import { color } from "d3";
import { byId } from "./shorthands"; import Delaunator from "delaunator";
import { rn } from "./numberUtils"; import {
type Cells,
type Point,
type Vertices,
Voronoi,
} from "../modules/voronoi";
import { createTypedArray } from "./arrayUtils"; import { createTypedArray } from "./arrayUtils";
import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; import { rn } from "./numberUtils";
import { byId } from "./shorthands";
/** /**
* Get boundary points on a regular square grid * Get boundary points on a regular square grid
@ -13,7 +18,11 @@ import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi";
* @param {number} spacing - The spacing between points * @param {number} spacing - The spacing between points
* @returns {Array} - An array of boundary points * @returns {Array} - An array of boundary points
*/ */
const getBoundaryPoints = (width: number, height: number, spacing: number): Point[] => { const getBoundaryPoints = (
width: number,
height: number,
spacing: number,
): Point[] => {
const offset = rn(-1 * spacing); const offset = rn(-1 * spacing);
const bSpacing = spacing * 2; const bSpacing = spacing * 2;
const w = width - offset * 2; const w = width - offset * 2;
@ -23,17 +32,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin
const points: Point[] = []; const points: Point[] = [];
for (let i = 0.5; i < numberX; i++) { for (let i = 0.5; i < numberX; i++) {
let x = Math.ceil((w * i) / numberX + offset); const x = Math.ceil((w * i) / numberX + offset);
points.push([x, offset], [x, h + offset]); points.push([x, offset], [x, h + offset]);
} }
for (let i = 0.5; i < numberY; i++) { for (let i = 0.5; i < numberY; i++) {
let y = Math.ceil((h * i) / numberY + offset); const y = Math.ceil((h * i) / numberY + offset);
points.push([offset, y], [w + offset, y]); points.push([offset, y], [w + offset, y]);
} }
return points; return points;
} };
/** /**
* Get points on a jittered square grid * Get points on a jittered square grid
@ -42,13 +51,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin
* @param {number} spacing - The spacing between points * @param {number} spacing - The spacing between points
* @returns {Array} - An array of jittered grid points * @returns {Array} - An array of jittered grid points
*/ */
const getJitteredGrid = (width: number, height: number, spacing: number): Point[] => { const getJitteredGrid = (
width: number,
height: number,
spacing: number,
): Point[] => {
const radius = spacing / 2; // square radius const radius = spacing / 2; // square radius
const jittering = radius * 0.9; // max deviation const jittering = radius * 0.9; // max deviation
const doubleJittering = jittering * 2; const doubleJittering = jittering * 2;
const jitter = () => Math.random() * doubleJittering - jittering; const jitter = () => Math.random() * doubleJittering - jittering;
let points: Point[] = []; const points: Point[] = [];
for (let y = radius; y < height; y += spacing) { for (let y = radius; y < height; y += spacing) {
for (let x = radius; x < width; x += spacing) { for (let x = radius; x < width; x += spacing) {
const xj = Math.min(rn(x + jitter(), 2), width); const xj = Math.min(rn(x + jitter(), 2), width);
@ -57,7 +70,7 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[
} }
} }
return points; return points;
} };
/** /**
* Places points on a jittered grid and calculates spacing and cell counts * Places points on a jittered grid and calculates spacing and cell counts
@ -65,7 +78,17 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[
* @param {number} graphHeight - The height 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 * @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} => { const placePoints = (
graphWidth: number,
graphHeight: number,
): {
spacing: number;
cellsDesired: number;
boundary: Point[];
points: Point[];
cellsX: number;
cellsY: number;
} => {
TIME && console.time("placePoints"); TIME && console.time("placePoints");
const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0); const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0);
const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering
@ -73,12 +96,20 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number,
const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing); const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid 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 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 const cellCountY = Math.floor(
(graphHeight + 0.5 * spacing - 1e-10) / spacing,
); // number of cells in y direction
TIME && console.timeEnd("placePoints"); TIME && console.timeEnd("placePoints");
return {spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY}; return {
} spacing,
cellsDesired,
boundary,
points,
cellsX: cellCountX,
cellsY: cellCountY,
};
};
/** /**
* Checks if the grid needs to be regenerated based on desired parameters * Checks if the grid needs to be regenerated based on desired parameters
@ -88,18 +119,34 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number,
* @param {number} graphHeight - The height of the graph * @param {number} graphHeight - The height of the graph
* @returns {boolean} - True if the grid should be regenerated, false otherwise * @returns {boolean} - True if the grid should be regenerated, false otherwise
*/ */
export const shouldRegenerateGrid = (grid: any, expectedSeed: number, graphWidth: number, graphHeight: number) => { export const shouldRegenerateGrid = (
grid: any,
expectedSeed: number,
graphWidth: number,
graphHeight: number,
) => {
if (expectedSeed && expectedSeed !== grid.seed) return true; if (expectedSeed && expectedSeed !== grid.seed) return true;
const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0); const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0);
if (cellsDesired !== grid.cellsDesired) return true; if (cellsDesired !== grid.cellsDesired) return true;
const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); const newSpacing = rn(
const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing); Math.sqrt((graphWidth * graphHeight) / cellsDesired),
const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing); 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; return (
} grid.spacing !== newSpacing ||
grid.cellsX !== newCellsX ||
grid.cellsY !== newCellsY
);
};
interface Grid { interface Grid {
spacing: number; spacing: number;
@ -116,12 +163,27 @@ interface Grid {
* Generates a Voronoi grid based on jittered grid points * 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 * @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 => { export const generateGrid = (
seed: string,
graphWidth: number,
graphHeight: number,
): Grid => {
Math.random = Alea(seed); // reset PRNG Math.random = Alea(seed); // reset PRNG
const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight); const { spacing, cellsDesired, boundary, points, cellsX, cellsY } =
const {cells, vertices} = calculateVoronoi(points, boundary); placePoints(graphWidth, graphHeight);
return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed}; 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 * Calculates the Voronoi diagram from given points and boundary
@ -129,7 +191,10 @@ export const generateGrid = (seed: string, graphWidth: number, graphHeight: numb
* @param {Array} boundary - The boundary points to clip the Voronoi cells * @param {Array} boundary - The boundary points to clip the Voronoi cells
* @returns {Object} - An object containing Voronoi cells and vertices * @returns {Object} - An object containing Voronoi cells and vertices
*/ */
export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Cells, vertices: Vertices} => { export const calculateVoronoi = (
points: Point[],
boundary: Point[],
): { cells: Cells; vertices: Vertices } => {
TIME && console.time("calculateDelaunay"); TIME && console.time("calculateDelaunay");
const allPoints = points.concat(boundary); const allPoints = points.concat(boundary);
const delaunay = Delaunator.from(allPoints); const delaunay = Delaunator.from(allPoints);
@ -139,12 +204,15 @@ export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Ce
const voronoi = new Voronoi(delaunay, allPoints, points.length); const voronoi = new Voronoi(delaunay, allPoints, points.length);
const cells = voronoi.cells; const cells = voronoi.cells;
cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i) as Uint32Array; // array of indexes cells.i = createTypedArray({
maxValue: points.length,
length: points.length,
}).map((_, i) => i) as Uint32Array; // array of indexes
const vertices = voronoi.vertices; const vertices = voronoi.vertices;
TIME && console.timeEnd("calculateVoronoi"); TIME && console.timeEnd("calculateVoronoi");
return {cells, vertices}; return { cells, vertices };
} };
/** /**
* Returns a cell index on a regular square grid based on x and y coordinates * Returns a cell index on a regular square grid based on x and y coordinates
@ -158,7 +226,7 @@ export const findGridCell = (x: number, y: number, grid: any): number => {
Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX +
Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1)) Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1))
); );
} };
/** /**
* return array of cell indexes in radius on a regular square grid * return array of cell indexes in radius on a regular square grid
@ -168,7 +236,12 @@ export const findGridCell = (x: number, y: number, grid: any): number => {
* @param {Object} grid - The grid object containing spacing, cellsX, and cellsY * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
* @returns {Array} - An array of cell indexes within the specified radius * @returns {Array} - An array of cell indexes within the specified radius
*/ */
export const findGridAll = (x: number, y: number, radius: number, grid: any): number[] => { export const findGridAll = (
x: number,
y: number,
radius: number,
grid: any,
): number[] => {
const c = grid.cells.c; const c = grid.cells.c;
let r = Math.floor(radius / grid.spacing); let r = Math.floor(radius / grid.spacing);
let found = [findGridCell(x, y, grid)]; let found = [findGridCell(x, y, grid)];
@ -177,10 +250,10 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu
if (r > 1) { if (r > 1) {
let frontier = c[found[0]]; let frontier = c[found[0]];
while (r > 1) { while (r > 1) {
let cycle = frontier.slice(); const cycle = frontier.slice();
frontier = []; frontier = [];
cycle.forEach(function (s: number) { cycle.forEach((s: number) => {
c[s].forEach(function (e: number) { c[s].forEach((e: number) => {
if (found.indexOf(e) !== -1) return; if (found.indexOf(e) !== -1) return;
found.push(e); found.push(e);
frontier.push(e); frontier.push(e);
@ -191,7 +264,7 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu
} }
return found; return found;
} };
/** /**
* Returns the index of the packed cell containing the given x and y coordinates * Returns the index of the packed cell containing the given x and y coordinates
@ -200,11 +273,16 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu
* @param {number} radius - The search radius (default is Infinity) * @param {number} radius - The search radius (default is Infinity)
* @returns {number|undefined} - The index of the found cell or undefined if not found * @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 => { export const findClosestCell = (
x: number,
y: number,
radius = Infinity,
packedGraph: any,
): number | undefined => {
if (!packedGraph.cells?.q) return; if (!packedGraph.cells?.q) return;
const found = packedGraph.cells.q.find(x, y, radius); const found = packedGraph.cells.q.find(x, y, radius);
return found ? found[2] : undefined; return found ? found[2] : undefined;
} };
/** /**
* Searches a quadtree for all points within a given radius * Searches a quadtree for all points within a given radius
@ -215,21 +293,31 @@ export const findClosestCell = (x: number, y: number, radius = Infinity, packedG
* @param {Object} quadtree - The D3 quadtree to search * @param {Object} quadtree - The D3 quadtree to search
* @returns {Array} - An array of found data points within the radius * @returns {Array} - An array of found data points within the radius
*/ */
export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { export const findAllInQuadtree = (
x: number,
y: number,
radius: number,
quadtree: any,
) => {
let dx: number, dy: number, d2: number;
const radiusSearchInit = (t: any, radius: number) => { const radiusSearchInit = (t: any, radius: number) => {
t.result = []; t.result = [];
(t.x0 = t.x - radius), (t.y0 = t.y - radius); t.x0 = t.x - radius;
(t.x3 = t.x + radius), (t.y3 = t.y + radius); t.y0 = t.y - radius;
t.x3 = t.x + radius;
t.y3 = t.y + radius;
t.radius = radius * radius; t.radius = radius * radius;
}; };
const radiusSearchVisit = (t: any, d2: number) => { const radiusSearchVisit = (t: any, d2: number) => {
t.node.data.scanned = true; t.node.data.scanned = true;
if (d2 < t.radius) { if (d2 < t.radius) {
do { while (t.node) {
t.result.push(t.node.data); t.result.push(t.node.data);
t.node.data.selected = true; t.node.data.selected = true;
} while ((t.node = t.node.next)); t.node = t.node.next;
}
} }
}; };
@ -248,39 +336,52 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree
} }
} }
const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; 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)); if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
radiusSearchInit(t, radius); radiusSearchInit(t, radius);
var i = 0; var _i = 0;
while ((t.q = t.quads.pop())) { t.q = t.quads.pop();
i++; while (t.q) {
_i++;
t.node = t.q.node;
t.x1 = t.q.x0;
t.y1 = t.q.y0;
t.x2 = t.q.x1;
t.y2 = t.q.y1;
// Stop searching if this quadrant can't contain a closer node. // Stop searching if this quadrant can't contain a closer node.
if ( if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) {
!(t.node = t.q.node) || t.q = t.quads.pop();
(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; continue;
}
// Bisect the current quadrant. // Bisect the current quadrant.
if (t.node.length) { if (t.node.length) {
t.node.explored = true; t.node.explored = true;
var xm: number = (t.x1 + t.x2) / 2, const xm: number = (t.x1 + t.x2) / 2,
ym: number = (t.y1 + t.y2) / 2; ym: number = (t.y1 + t.y2) / 2;
t.quads.push( t.quads.push(
new Quad(t.node[3], xm, ym, t.x2, t.y2), 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[2], t.x1, ym, xm, t.y2),
new Quad(t.node[1], xm, t.y1, t.x2, ym), new Quad(t.node[1], xm, t.y1, t.x2, ym),
new Quad(t.node[0], t.x1, t.y1, xm, ym) new Quad(t.node[0], t.x1, t.y1, xm, ym),
); );
// Visit the closest quadrant first. // Visit the closest quadrant first.
if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { t.i = (+(y >= ym) << 1) | +(x >= xm);
if (t.i) {
t.q = t.quads[t.quads.length - 1]; 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.quads[t.quads.length - 1 - t.i];
t.quads[t.quads.length - 1 - t.i] = t.q; t.quads[t.quads.length - 1 - t.i] = t.q;
@ -289,14 +390,15 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree
// Visit this point. (Visiting coincident points isn't necessary!) // Visit this point. (Visiting coincident points isn't necessary!)
else { else {
var dx = x - +quadtree._x.call(null, t.node.data), dx = x - +quadtree._x.call(null, t.node.data);
dy = y - +quadtree._y.call(null, t.node.data), dy = y - +quadtree._y.call(null, t.node.data);
d2 = dx * dx + dy * dy; d2 = dx * dx + dy * dy;
radiusSearchVisit(t, d2); radiusSearchVisit(t, d2);
} }
t.q = t.quads.pop();
} }
return t.result; return t.result;
} };
/** /**
* Returns an array of packed cell indexes within a specified radius from given x and y coordinates * Returns an array of packed cell indexes within a specified radius from given x and y coordinates
@ -306,11 +408,16 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree
* @param {Object} packedGraph - The packed graph containing cells with quadtree * @param {Object} packedGraph - The packed graph containing cells with quadtree
* @returns {number[]} - An array of cell indexes within the radius * @returns {number[]} - An array of cell indexes within the radius
*/ */
export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => { export const findAllCellsInRadius = (
x: number,
y: number,
radius: number,
packedGraph: any,
): number[] => {
// Use findAllInQuadtree directly instead of relying on prototype extension // Use findAllInQuadtree directly instead of relying on prototype extension
const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q);
return found.map((r: any) => r[2]); return found.map((r: any) => r[2]);
} };
/** /**
* Returns the polygon points for a packed cell given its index * Returns the polygon points for a packed cell given its index
@ -318,8 +425,10 @@ export const findAllCellsInRadius = (x: number, y: number, radius: number, packe
* @returns {Array} - An array of polygon points for the specified cell * @returns {Array} - An array of polygon points for the specified cell
*/ */
export const getPackPolygon = (cellIndex: number, packedGraph: any) => { export const getPackPolygon = (cellIndex: number, packedGraph: any) => {
return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]); return packedGraph.cells.v[cellIndex].map(
} (v: number) => packedGraph.vertices.p[v],
);
};
/** /**
* Returns the polygon points for a grid cell given its index * Returns the polygon points for a grid cell given its index
@ -328,7 +437,7 @@ export const getPackPolygon = (cellIndex: number, packedGraph: any) => {
*/ */
export const getGridPolygon = (i: number, grid: any) => { export const getGridPolygon = (i: number, grid: any) => {
return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); return grid.cells.v[i].map((v: number) => grid.vertices.p[v]);
} };
/** /**
* mbostock's poissonDiscSampler implementation * mbostock's poissonDiscSampler implementation
@ -341,7 +450,14 @@ export const getGridPolygon = (i: number, grid: any) => {
* @param {number} k - The number of attempts before rejection (default is 3) * @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 * @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) { 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(); if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error();
const width = x1 - x0; const width = x1 - x0;
@ -377,7 +493,8 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb
function sample(x: number, y: number) { function sample(x: number, y: number) {
const point: [number, number] = [x, y]; const point: [number, number] = [x, y];
queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point)); grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point;
queue.push(point);
return [x + x0, y + y0]; return [x + x0, y + y0];
} }
@ -410,7 +527,7 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb
*/ */
export const isLand = (i: number, packedGraph: any) => { export const isLand = (i: number, packedGraph: any) => {
return packedGraph.cells.h[i] >= 20; return packedGraph.cells.h[i] >= 20;
} };
/** /**
* Checks if a packed cell is water based on its height * Checks if a packed cell is water based on its height
@ -419,8 +536,7 @@ export const isLand = (i: number, packedGraph: any) => {
*/ */
export const isWater = (i: number, packedGraph: any) => { export const isWater = (i: number, packedGraph: any) => {
return packedGraph.cells.h[i] < 20; return packedGraph.cells.h[i] < 20;
} };
// draw raster heightmap preview (not used in main generation) // draw raster heightmap preview (not used in main generation)
/** /**
@ -433,18 +549,31 @@ export const isWater = (i: number, packedGraph: any) => {
* @param {boolean} options.renderOcean - Whether to render ocean heights * @param {boolean} options.renderOcean - Whether to render ocean heights
* @returns {string} - A data URL representing the drawn heightmap image * @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}) => { 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"); const canvas = document.createElement("canvas");
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
const getHeight = (height: number) => (height < 20 ? (renderOcean ? height : 0) : height); const getHeight = (height: number) =>
height < 20 ? (renderOcean ? height : 0) : height;
for (let i = 0; i < heights.length; i++) { for (let i = 0; i < heights.length; i++) {
const colorScheme = scheme(1 - getHeight(heights[i]) / 100); const colorScheme = scheme(1 - getHeight(heights[i]) / 100);
const {r, g, b} = color(colorScheme)!.rgb(); const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 };
const n = i * 4; const n = i * 4;
imageData.data[n] = r; imageData.data[n] = r;
@ -455,12 +584,11 @@ export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heig
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png"); return canvas.toDataURL("image/png");
} };
declare global { declare global {
var TIME: boolean; var TIME: boolean;
interface Window { interface Window {
shouldRegenerateGrid: typeof shouldRegenerateGrid; shouldRegenerateGrid: typeof shouldRegenerateGrid;
generateGrid: typeof generateGrid; generateGrid: typeof generateGrid;
findCell: typeof findClosestCell; findCell: typeof findClosestCell;

View file

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

View file

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

View file

@ -3,14 +3,14 @@
* @param {Node | Window} node - The starting node or window * @param {Node | Window} node - The starting node or window
* @returns {Array<Node>} - The composed path as an array * @returns {Array<Node>} - The composed path as an array
*/ */
export const getComposedPath = function(node: any): Array<Node | Window> { export const getComposedPath = (node: any): Array<Node | Window> => {
let parent; let parent: Node | Window | undefined;
if (node.parentNode) parent = node.parentNode; if (node.parentNode) parent = node.parentNode;
else if (node.host) parent = node.host; else if (node.host) parent = node.host;
else if (node.defaultView) parent = node.defaultView; else if (node.defaultView) parent = node.defaultView;
if (parent !== undefined) return [node].concat(getComposedPath(parent)); if (parent !== undefined) return [node].concat(getComposedPath(parent));
return [node]; return [node];
} };
/** /**
* Generate a unique ID for a given core string * Generate a unique ID for a given core string
@ -18,10 +18,10 @@ export const getComposedPath = function(node: any): Array<Node | Window> {
* @param {number} [i=1] - The starting index * @param {number} [i=1] - The starting index
* @returns {string} - The unique ID * @returns {string} - The unique ID
*/ */
export const getNextId = function(core: string, i: number = 1): string { export const getNextId = (core: string, i: number = 1): string => {
while (document.getElementById(core + i)) i++; while (document.getElementById(core + i)) i++;
return core + i; return core + i;
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -5,9 +5,9 @@
* @returns The rounded number. * @returns The rounded number.
*/ */
export const rn = (v: number, d: number = 0) => { export const rn = (v: number, d: number = 0) => {
const m = Math.pow(10, d); const m = 10 ** d;
return Math.round(v * m) / m; return Math.round(v * m) / m;
} };
/** /**
* Clamps a number between a minimum and maximum value. * Clamps a number between a minimum and maximum value.
@ -18,7 +18,7 @@ export const rn = (v: number, d: number = 0) => {
*/ */
export const minmax = (value: number, min: number, max: number) => { export const minmax = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} };
/** /**
* Clamps a number between 0 and 100. * Clamps a number between 0 and 100.
@ -27,7 +27,7 @@ export const minmax = (value: number, min: number, max: number) => {
*/ */
export const lim = (v: number) => { export const lim = (v: number) => {
return minmax(v, 0, 100); return minmax(v, 0, 100);
} };
/** /**
* Normalizes a number within a specified range to a value between 0 and 1. * Normalizes a number within a specified range to a value between 0 and 1.
@ -38,7 +38,7 @@ export const lim = (v: number) => {
*/ */
export const normalize = (val: number, min: number, max: number) => { export const normalize = (val: number, min: number, max: number) => {
return minmax((val - min) / (max - min), 0, 1); return minmax((val - min) / (max - min), 0, 1);
} };
/** /**
* Performs linear interpolation between two values. * Performs linear interpolation between two values.
@ -49,7 +49,7 @@ export const normalize = (val: number, min: number, max: number) => {
*/ */
export const lerp = (a: number, b: number, t: number) => { export const lerp = (a: number, b: number, t: number) => {
return a + (b - a) * t; return a + (b - a) * t;
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -8,10 +8,10 @@ import { rn } from "./numberUtils";
* @returns {string} SVG path data for the filled shape. * @returns {string} SVG path data for the filled shape.
*/ */
const getFillPath = (vertices: any, vertexChain: number[]) => { const getFillPath = (vertices: any, vertexChain: number[]) => {
const points = vertexChain.map(vertexId => vertices.p[vertexId]); const points = vertexChain.map((vertexId) => vertices.p[vertexId]);
const firstPoint = points.shift(); const firstPoint = points.shift();
return `M${firstPoint} L${points.join(" ")} Z`; return `M${firstPoint} L${points.join(" ")} Z`;
} };
/** /**
* Generates SVG path data for borders based on a chain of vertices and a discontinuation condition. * Generates SVG path data for borders based on a chain of vertices and a discontinuation condition.
@ -20,10 +20,14 @@ const getFillPath = (vertices: any, vertexChain: number[]) => {
* @param {(vertexId: number) => boolean} discontinue - A function that determines if the path should discontinue at a vertex. * @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. * @returns {string} SVG path data for the border.
*/ */
const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (vertexId: number) => boolean) => { const getBorderPath = (
vertices: any,
vertexChain: number[],
discontinue: (vertexId: number) => boolean,
) => {
let discontinued = true; let discontinued = true;
let lastOperation = ""; let lastOperation = "";
const path = vertexChain.map(vertexId => { const path = vertexChain.map((vertexId) => {
if (discontinue(vertexId)) { if (discontinue(vertexId)) {
discontinued = true; discontinued = true;
return ""; return "";
@ -33,12 +37,13 @@ const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (verte
discontinued = false; discontinued = false;
lastOperation = operation; lastOperation = operation;
const command = operation === "L" && operation === lastOperation ? "" : operation; const command =
operation === "L" && operation === lastOperation ? "" : operation;
return ` ${command}${vertices.p[vertexId]}`; return ` ${command}${vertices.p[vertexId]}`;
}); });
return path.join("").trim(); return path.join("").trim();
} };
/** /**
* Restores the path from exit to start using the 'from' mapping. * Restores the path from exit to start using the 'from' mapping.
@ -62,7 +67,7 @@ const restorePath = (exit: number, start: number, from: number[]) => {
pathCells.push(current); pathCells.push(current);
return pathCells.reverse(); return pathCells.reverse();
} };
/** /**
* Returns isolines (borders) for different types of cells in the graph. * Returns isolines (borders) for different types of cells in the graph.
@ -75,12 +80,23 @@ const restorePath = (exit: number, start: number, from: number[]) => {
* @param {boolean} [options.waterGap=false] - Whether to generate water gap 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. * @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 => { export const getIsolines = (
const {cells, vertices} = graph; 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 isolines: any = {};
const checkedCells = new Uint8Array(cells.i.length); const checkedCells = new Uint8Array(cells.i.length);
const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); const addToChecked = (cellId: number) => {
checkedCells[cellId] = 1;
};
const isChecked = (cellId: number) => checkedCells[cellId] === 1; const isChecked = (cellId: number) => checkedCells[cellId] === 1;
for (const cellId of cells.i) { for (const cellId of cells.i) {
@ -96,12 +112,22 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option
// check if inner lake. Note there is no shoreline for grid features // check if inner lake. Note there is no shoreline for grid features
const feature = graph.features[cells.f[onborderCell]]; const feature = graph.features[cells.f[onborderCell]];
if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue; if (feature.type === "lake" && feature.shoreline?.every(ofSameType))
continue;
const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); const startingVertex = cells.v[cellId].find((v: number) =>
if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); 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}); const vertexChain = connectVertices({
vertices,
startingVertex,
ofSameType,
addToChecked,
closeRing: true,
});
if (vertexChain.length < 3) continue; if (vertexChain.length < 3) continue;
addIsolineTo(type, vertices, vertexChain, isolines, options); addIsolineTo(type, vertices, vertexChain, isolines, options);
@ -109,12 +135,20 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option
return isolines; return isolines;
function addIsolineTo(type: any, vertices: any, vertexChain: number[], isolines: any, options: any) { function addIsolineTo(
type: any,
vertices: any,
vertexChain: number[],
isolines: any,
options: any,
) {
if (!isolines[type]) isolines[type] = {}; if (!isolines[type]) isolines[type] = {};
if (options.polygons) { if (options.polygons) {
if (!isolines[type].polygons) isolines[type].polygons = []; if (!isolines[type].polygons) isolines[type].polygons = [];
isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId])); isolines[type].polygons.push(
vertexChain.map((vertexId) => vertices.p[vertexId]),
);
} }
if (options.fill) { if (options.fill) {
@ -124,18 +158,27 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option
if (options.waterGap) { if (options.waterGap) {
if (!isolines[type].waterGap) isolines[type].waterGap = ""; if (!isolines[type].waterGap) isolines[type].waterGap = "";
const isLandVertex = (vertexId: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); const isLandVertex = (vertexId: number) =>
isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex); vertices.c[vertexId].every((i: number) => cells.h[i] >= 20);
isolines[type].waterGap += getBorderPath(
vertices,
vertexChain,
isLandVertex,
);
} }
if (options.halo) { if (options.halo) {
if (!isolines[type].halo) isolines[type].halo = ""; if (!isolines[type].halo) isolines[type].halo = "";
const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]); const isBorderVertex = (vertexId: number) =>
isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex); 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. * Generates SVG path data for the border of a shape defined by a chain of vertices.
@ -144,14 +187,18 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option
* @returns {string} SVG path data for the border of the shape. * @returns {string} SVG path data for the border of the shape.
*/ */
export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => {
const {cells, vertices} = packedGraph; const { cells, vertices } = packedGraph;
const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true])); const cellsObj = Object.fromEntries(
cellsArray.map((cellId) => [cellId, true]),
);
const ofSameType = (cellId: number) => cellsObj[cellId]; const ofSameType = (cellId: number) => cellsObj[cellId];
const ofDifferentType = (cellId: number) => !cellsObj[cellId]; const ofDifferentType = (cellId: number) => !cellsObj[cellId];
const checkedCells = new Uint8Array(cells.c.length); const checkedCells = new Uint8Array(cells.c.length);
const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); const addToChecked = (cellId: number) => {
checkedCells[cellId] = 1;
};
const isChecked = (cellId: number) => checkedCells[cellId] === 1; const isChecked = (cellId: number) => checkedCells[cellId] === 1;
let path = ""; let path = "";
@ -166,17 +213,26 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => {
if (feature.shoreline.every(ofSameType)) continue; // inner lake if (feature.shoreline.every(ofSameType)) continue; // inner lake
} }
const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); const startingVertex = cells.v[cellId].find((v: number) =>
if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); 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}); const vertexChain = connectVertices({
vertices,
startingVertex,
ofSameType,
addToChecked,
closeRing: true,
});
if (vertexChain.length < 3) continue; if (vertexChain.length < 3) continue;
path += getFillPath(vertices, vertexChain); path += getFillPath(vertices, vertexChain);
} }
return path; return path;
} };
/** /**
* Finds the poles of inaccessibility for each type of cell in the graph. * Finds the poles of inaccessibility for each type of cell in the graph.
@ -184,17 +240,22 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => {
* @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID. * @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]. * @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y].
*/ */
export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => { export const getPolesOfInaccessibility = (
const isolines = getIsolines(graph, getType, {polygons: true}); graph: any,
getType: (cellId: number) => any,
) => {
const isolines = getIsolines(graph, getType, { polygons: true });
const poles = Object.entries(isolines).map(([id, isoline]) => { const poles = Object.entries(isolines).map(([id, isoline]) => {
const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length); const multiPolygon = (isoline as any).polygons.sort(
(a: any, b: any) => b.length - a.length,
);
const [x, y] = polylabel(multiPolygon, 20); const [x, y] = polylabel(multiPolygon, 20);
return [id, [rn(x), rn(y)]]; return [id, [rn(x), rn(y)]];
}); });
return Object.fromEntries(poles); return Object.fromEntries(poles);
} };
/** /**
* Connects vertices to form a closed path based on cell type. * Connects vertices to form a closed path based on cell type.
@ -206,7 +267,19 @@ export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number)
* @param {boolean} [options.closeRing=false] - Whether to close the path into a ring. * @param {boolean} [options.closeRing=false] - Whether to close the path into a ring.
* @returns {number[]} An array of vertex IDs forming the connected path. * @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}) => { 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 MAX_ITERATIONS = vertices.c.length;
const chain = []; // vertices chain to form a path const chain = []; // vertices chain to form a path
@ -227,24 +300,30 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec
else if (v3 !== previous && c1 !== c3) next = v3; else if (v3 !== previous && c1 !== c3) next = v3;
if (next >= vertices.c.length) { if (next >= vertices.c.length) {
window.ERROR && console.error("ConnectVertices: next vertex is out of bounds"); window.ERROR &&
console.error("ConnectVertices: next vertex is out of bounds");
break; break;
} }
if (next === current) { if (next === current) {
window.ERROR && console.error("ConnectVertices: next vertex is not found"); window.ERROR &&
console.error("ConnectVertices: next vertex is not found");
break; break;
} }
if (i === MAX_ITERATIONS) { if (i === MAX_ITERATIONS) {
window.ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS); window.ERROR &&
console.error(
"ConnectVertices: max iterations reached",
MAX_ITERATIONS,
);
break; break;
} }
} }
if (closeRing) chain.push(startingVertex); if (closeRing) chain.push(startingVertex);
return chain; return chain;
} };
/** /**
* Finds the shortest path between two cells using a cost-based pathfinding algorithm. * Finds the shortest path between two cells using a cost-based pathfinding algorithm.
@ -254,7 +333,12 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec
* @param {object} packedGraph - The packed graph object containing cells and their 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. * @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 => { export const findPath = (
start: number,
isExit: (id: number) => boolean,
getCost: (current: number, next: number) => number,
packedGraph: any = {},
): number[] | null => {
if (isExit(start)) return null; if (isExit(start)) return null;
const from = []; const from = [];
@ -284,7 +368,7 @@ export const findPath = (start: number, isExit: (id: number) => boolean, getCost
} }
return null; return null;
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -1,7 +1,11 @@
// replaceAll // replaceAll
if (String.prototype.replaceAll === undefined) { if (String.prototype.replaceAll === undefined) {
String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string { String.prototype.replaceAll = function (
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any); 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); return this.replace(new RegExp(str, "g"), newStr as any);
}; };
} }
@ -9,7 +13,13 @@ if (String.prototype.replaceAll === undefined) {
// flat // flat
if (Array.prototype.flat === undefined) { if (Array.prototype.flat === undefined) {
Array.prototype.flat = function <T>(this: T[], depth?: number): any[] { 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)), []); return (this as Array<unknown>).reduce(
(acc: any[], val: unknown) =>
Array.isArray(val)
? acc.concat((val as any).flat(depth))
: acc.concat(val),
[],
);
}; };
} }
@ -24,11 +34,13 @@ if (Array.prototype.at === undefined) {
// readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10 // readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10
if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) {
(ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* <R>(this: ReadableStream<R>): AsyncGenerator<R, void, unknown> { (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* <R>(
this: ReadableStream<R>,
): AsyncGenerator<R, void, unknown> {
const reader = this.getReader(); const reader = this.getReader();
try { try {
while (true) { while (true) {
const {done, value} = await reader.read(); const { done, value } = await reader.read();
if (done) return; if (done) return;
yield value; yield value;
} }
@ -40,7 +52,10 @@ if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) {
declare global { declare global {
interface String { interface String {
replaceAll(searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string; replaceAll(
searchValue: string | RegExp,
replaceValue: string | ((substring: string, ...args: any[]) => string),
): string;
} }
interface Array<T> { interface Array<T> {

View file

@ -1,5 +1,5 @@
import { minmax, rn } from "./numberUtils";
import { randomNormal } from "d3"; import { randomNormal } from "d3";
import { minmax, rn } from "./numberUtils";
/** /**
* Creates a random number between min and max (inclusive). * Creates a random number between min and max (inclusive).
@ -14,7 +14,7 @@ export const rand = (min: number, max?: number): number => {
min = 0; min = 0;
} }
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} };
/** /**
* Returns a boolean based on the given probability. * Returns a boolean based on the given probability.
@ -25,7 +25,7 @@ export const P = (probability: number): boolean => {
if (probability >= 1) return true; if (probability >= 1) return true;
if (probability <= 0) return false; if (probability <= 0) return false;
return Math.random() < probability; return Math.random() < probability;
} };
/** /**
* Returns true every n times. * Returns true every n times.
@ -34,7 +34,7 @@ export const P = (probability: number): boolean => {
*/ */
export const each = (n: number) => { export const each = (n: number) => {
return (i: number) => i % n === 0; return (i: number) => i % n === 0;
} };
/** /**
* Random Gaussian number generator * Random Gaussian number generator
@ -46,10 +46,23 @@ export const each = (n: number) => {
* @param {number} round - round value to n decimals * @param {number} round - round value to n decimals
* @return {number} random number * @return {number} random number
*/ */
export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { export const gauss = (
expected = 100,
deviation = 30,
min = 0,
max = 300,
round = 0,
) => {
// Use .source() to get a version that uses the current Math.random (which may be seeded) // Use .source() to get a version that uses the current Math.random (which may be seeded)
return rn(minmax(randomNormal.source(() => Math.random())(expected, deviation)(), min, max), round); return rn(
} minmax(
randomNormal.source(() => Math.random())(expected, deviation)(),
min,
max,
),
round,
);
};
/** /**
* Returns the integer part of a float plus one with the probability of the decimal part. * Returns the integer part of a float plus one with the probability of the decimal part.
@ -58,7 +71,7 @@ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round
*/ */
export const Pint = (float: number): number => { export const Pint = (float: number): number => {
return ~~float + +P(float % 1); return ~~float + +P(float % 1);
} };
/** /**
* Returns a random element from an array. * Returns a random element from an array.
@ -67,7 +80,7 @@ export const Pint = (float: number): number => {
*/ */
export const ra = (array: any[]): any => { export const ra = (array: any[]): any => {
return array[Math.floor(Math.random() * array.length)]; return array[Math.floor(Math.random() * array.length)];
} };
/** /**
* Returns a random key from an object where values are weights. * Returns a random key from an object where values are weights.
@ -78,7 +91,7 @@ export const ra = (array: any[]): any => {
* const obj = { a: 1, b: 3, c: 6 }; * const obj = { a: 1, b: 3, c: 6 };
* const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance * const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance
*/ */
export const rw = (object: {[key: string]: number}): string => { export const rw = (object: { [key: string]: number }): string => {
const array = []; const array = [];
for (const key in object) { for (const key in object) {
for (let i = 0; i < object[key]; i++) { for (let i = 0; i < object[key]; i++) {
@ -86,7 +99,7 @@ export const rw = (object: {[key: string]: number}): string => {
} }
} }
return array[Math.floor(Math.random() * array.length)]; 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). * Returns a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min).
@ -96,8 +109,8 @@ export const rw = (object: {[key: string]: number}): string => {
* @return {number} biased random integer * @return {number} biased random integer
*/ */
export const biased = (min: number, max: number, ex: number): number => { export const biased = (min: number, max: number, ex: number): number => {
return Math.round(min + (max - min) * Math.pow(Math.random(), ex)); return Math.round(min + (max - min) * Math.random() ** ex);
} };
const ERROR = false; const ERROR = false;
/** /**
@ -110,28 +123,28 @@ export const getNumberInRange = (r: string): number => {
ERROR && console.error("Range value should be a string", r); ERROR && console.error("Range value should be a string", r);
return 0; return 0;
} }
if (!isNaN(+r)) return ~~r + +P(+r - ~~r); if (!Number.isNaN(+r)) return ~~r + +P(+r - ~~r);
const sign = r[0] === "-" ? -1 : 1; const sign = r[0] === "-" ? -1 : 1;
if (isNaN(+r[0])) r = r.slice(1); if (Number.isNaN(+r[0])) r = r.slice(1);
const range = r.includes("-") ? r.split("-") : null; const range = r.includes("-") ? r.split("-") : null;
if (!range) { if (!range) {
ERROR && console.error("Cannot parse the number. Check the format", r); ERROR && console.error("Cannot parse the number. Check the format", r);
return 0; return 0;
} }
const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1])); const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1]));
if (isNaN(count) || count < 0) { if (Number.isNaN(count) || count < 0) {
ERROR && console.error("Cannot parse number. Check the format", r); ERROR && console.error("Cannot parse number. Check the format", r);
return 0; return 0;
} }
return count; return count;
} };
/** /**
* Generate a random seed string * Generate a random seed string
* @return {string} random seed * @return {string} random seed
*/ */
export const generateSeed = (): string => { export const generateSeed = (): string => {
return String(Math.floor(Math.random() * 1e9)); return String(Math.floor(Math.random() * 1e9));
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -1,8 +1,8 @@
import { expect, describe, it } from 'vitest' import { describe, expect, it } from "vitest";
import { round } from './stringUtils' import { round } from "./stringUtils";
describe('round', () => { describe("round", () => {
it('should be able to handle undefined input', () => { it("should be able to handle undefined input", () => {
expect(round(undefined)).toBe(""); expect(round(undefined)).toBe("");
}); });
}) });

View file

@ -7,10 +7,10 @@ import { rn } from "./numberUtils";
* @returns {string} - The string with rounded numbers * @returns {string} - The string with rounded numbers
*/ */
export const round = (inputString: string = "", decimals: number = 1) => { export const round = (inputString: string = "", decimals: number = 1) => {
return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { return inputString.replace(/[\d.-][\d.e-]*/g, (n: string) => {
return rn(parseFloat(n), decimals).toString(); return rn(parseFloat(n), decimals).toString();
}); });
} };
/** /**
* Capitalize the first letter of a string * Capitalize the first letter of a string
@ -19,7 +19,7 @@ export const round = (inputString: string = "", decimals: number = 1) => {
*/ */
export const capitalize = (inputString: string) => { export const capitalize = (inputString: string) => {
return inputString.charAt(0).toUpperCase() + inputString.slice(1); return inputString.charAt(0).toUpperCase() + inputString.slice(1);
} };
/** /**
* Split a string into two parts, trying to balance their lengths * Split a string into two parts, trying to balance their lengths
@ -46,7 +46,7 @@ export const splitInTwo = (inputString: string): string[] => {
if (!last) return [first, middle]; if (!last) return [first, middle];
if (first.length < last.length) return [first + middle, last]; if (first.length < last.length) return [first + middle, last];
return [first, middle + last]; return [first, middle + last];
} };
/** /**
* Parse an SVG transform string into an array of numbers * Parse an SVG transform string into an array of numbers
@ -65,7 +65,7 @@ export const parseTransform = (string: string) => {
.replace(/[ ]/g, ",") .replace(/[ ]/g, ",")
.split(","); .split(",");
return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1]; 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 * Check if a string is valid JSON
@ -76,7 +76,7 @@ export const isValidJSON = (str: string): boolean => {
try { try {
JSON.parse(str); JSON.parse(str);
return true; return true;
} catch (e) { } catch (_e) {
return false; return false;
} }
}; };
@ -89,7 +89,7 @@ export const isValidJSON = (str: string): boolean => {
export const safeParseJSON = (str: string) => { export const safeParseJSON = (str: string) => {
try { try {
return JSON.parse(str); return JSON.parse(str);
} catch (e) { } catch (_e) {
return null; return null;
} }
}; };
@ -109,10 +109,10 @@ export const sanitizeId = (inputString: string) => {
.replace(/\s+/g, "-"); // replace spaces with hyphens .replace(/\s+/g, "-"); // replace spaces with hyphens
// remove leading numbers // remove leading numbers
if (sanitized.match(/^\d/)) sanitized = "_" + sanitized; if (sanitized.match(/^\d/)) sanitized = `_${sanitized}`;
return sanitized; return sanitized;
} };
declare global { declare global {
interface Window { interface Window {

View file

@ -7,19 +7,23 @@ type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" |
* @param {string} targetScale - Target temperature scale * @param {string} targetScale - Target temperature scale
* @returns {string} - Converted temperature with unit * @returns {string} - Converted temperature with unit
*/ */
export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => { export const convertTemperature = (
const temperatureConversionMap: {[key: string]: (temp: number) => string} = { temperatureInCelsius: number,
"°C": (temp: number) => rn(temp) + "°C", targetScale: TemperatureScale = "°C",
"°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F", ) => {
K: (temp: number) => rn(temp + 273.15) + "K", const temperatureConversionMap: { [key: string]: (temp: number) => string } =
"°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R", {
"°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De", "°C": (temp: number) => `${rn(temp)}°C`,
"°N": (temp: number) => rn((temp * 33) / 100) + "°N", "°F": (temp: number) => `${rn((temp * 9) / 5 + 32)}°F`,
"°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré", K: (temp: number) => `${rn(temp + 273.15)}K`,
"°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø" "°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); return temperatureConversionMap[targetScale](temperatureInCelsius);
} };
/** /**
* Convert number to short string with SI postfix * Convert number to short string with SI postfix
@ -27,13 +31,13 @@ export const convertTemperature = (temperatureInCelsius: number, targetScale: Te
* @returns {string} - The converted string * @returns {string} - The converted string
*/ */
export const si = (n: number): string => { export const si = (n: number): string => {
if (n >= 1e9) return rn(n / 1e9, 1) + "B"; if (n >= 1e9) return `${rn(n / 1e9, 1)}B`;
if (n >= 1e8) return rn(n / 1e6) + "M"; if (n >= 1e8) return `${rn(n / 1e6)}M`;
if (n >= 1e6) return rn(n / 1e6, 1) + "M"; if (n >= 1e6) return `${rn(n / 1e6, 1)}M`;
if (n >= 1e4) return rn(n / 1e3) + "K"; if (n >= 1e4) return `${rn(n / 1e3)}K`;
if (n >= 1e3) return rn(n / 1e3, 1) + "K"; if (n >= 1e3) return `${rn(n / 1e3, 1)}K`;
return rn(n).toString(); return rn(n).toString();
} };
/** /**
* Convert string with SI postfix to integer * Convert string with SI postfix to integer
@ -42,11 +46,11 @@ export const si = (n: number): string => {
*/ */
export const getIntegerFromSI = (value: string): number => { export const getIntegerFromSI = (value: string): number => {
const metric = value.slice(-1); const metric = value.slice(-1);
if (metric === "K") return parseInt(value.slice(0, -1)) * 1e3; if (metric === "K") return parseInt(value.slice(0, -1), 10) * 1e3;
if (metric === "M") return parseInt(value.slice(0, -1)) * 1e6; if (metric === "M") return parseInt(value.slice(0, -1), 10) * 1e6;
if (metric === "B") return parseInt(value.slice(0, -1)) * 1e9; if (metric === "B") return parseInt(value.slice(0, -1), 10) * 1e9;
return parseInt(value); return parseInt(value, 10);
} };
declare global { declare global {
interface Window { interface Window {