diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..dc7ad769 --- /dev/null +++ b/.github/workflows/lint.yml @@ -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 . \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..7d02cf0f --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -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 \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..84644252 --- /dev/null +++ b/biome.json @@ -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" + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 55b1e80f..428c81c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "polylabel": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "2.3.12", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -31,6 +32,169 @@ "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": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2025,7 +2189,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, diff --git a/package.json b/package.json index 9d3fbe11..4656ff9a 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,12 @@ "preview": "vite preview", "test": "vitest", "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": { + "@biomejs/biome": "2.3.12", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", diff --git a/public/main.js b/public/main.js index e922c44e..c0ac9d11 100644 --- a/public/main.js +++ b/public/main.js @@ -187,7 +187,7 @@ const onZoom = debounce(function () { }, 50); 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 distanceScale = +byId("distanceScaleInput").value; let urbanization = +byId("urbanizationInput").value; diff --git a/src/modules/biomes.ts b/src/modules/biomes.ts index 321ea77a..b708589f 100644 --- a/src/modules/biomes.ts +++ b/src/modules/biomes.ts @@ -1,4 +1,4 @@ -import { range, mean } from "d3"; +import { mean, range } from "d3"; import { rn } from "../utils"; declare global { @@ -22,7 +22,7 @@ class BiomesModule { "Taiga", "Tundra", "Glacier", - "Wetland" + "Wetland", ]; const color: string[] = [ @@ -38,33 +38,54 @@ class BiomesModule { "#4b6b32", "#96784b", "#d5e7eb", - "#0b9131" + "#0b9131", ]; - const habitability: number[] = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; - 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 habitability: number[] = [ + 0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12, ]; - 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[] = [ // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet - new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]), - new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10, 10]) + new Uint8Array([ + 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 10, + ]), + new Uint8Array([ + 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, + 10, 10, + ]), ]; // parse icons weighted array into a simple array @@ -79,14 +100,29 @@ class BiomesModule { 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() { TIME && console.time("defineBiomes"); - const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells; - const {temp, prec} = grid.cells; + const { + 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 const calculateMoisture = (cellId: number) => { @@ -94,23 +130,36 @@ class BiomesModule { if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); 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]]) .concat([moisture]); return rn(4 + (mean(moistAround) as number)); - } + }; for (let cellId = 0; cellId < heights.length; 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]]; - 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"); } - 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 (temperature < -5) return 11; // too cold: permafrost biome if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome diff --git a/src/modules/features.ts b/src/modules/features.ts index bedb48ff..06984af6 100644 --- a/src/modules/features.ts +++ b/src/modules/features.ts @@ -1,6 +1,16 @@ -import { clipPoly, connectVertices, createTypedArray, distanceSquared, isLand, isWater, rn, TYPED_ARRAY_MAX_VALUES, unique } from "../utils"; import Alea from "alea"; import { polygonArea } from "d3"; +import { + clipPoly, + connectVertices, + createTypedArray, + distanceSquared, + isLand, + isWater, + rn, + TYPED_ARRAY_MAX_VALUES, + unique, +} from "../utils"; declare global { var Features: FeatureModule; @@ -52,14 +62,24 @@ class FeatureModule { /** * 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; neighbors: number[][]; start: number; increment: 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; const prevDistance = distance - increment; for (let cellId = 0; cellId < neighbors.length; cellId++) { @@ -115,11 +135,17 @@ class FeatureModule { const type = land ? "island" : border ? "ocean" : "lake"; 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 - 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.f = featureIds; grid.features = [0, ...features]; @@ -132,15 +158,22 @@ class FeatureModule { */ markupPack() { const defineHaven = (cellId: number) => { - const waterCells = neighbors[cellId].filter((index: number) => isWater(index, pack)); - const distances = waterCells.map((neibCellId: number) => distanceSquared(cells.p[cellId], cells.p[neibCellId])); + const waterCells = neighbors[cellId].filter((index: number) => + 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)); haven[cellId] = waterCells[closest]; 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, []]; const getType = (cellId: number) => featureIds[cellId]; @@ -153,29 +186,55 @@ class FeatureModule { return [startCell, featureVertices]; 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; const startCell = cells.i.filter(ofSameType).find(isOnBorder); 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; } 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) - 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 [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 absArea = Math.abs(rn(area)); @@ -193,20 +252,20 @@ class FeatureModule { }; 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.vertices as number[]) - .flatMap( - vertexIndex => vertices.c[vertexIndex].filter((index) => isLand(index, pack)) - ) + (feature.vertices as number[]).flatMap((vertexIndex) => + vertices.c[vertexIndex].filter((index) => isLand(index, pack)), + ), ); feature.height = Lakes.getHeight(feature as PackedGraphFeature); } return { - ...feature + ...feature, } as PackedGraphFeature; - } + }; TIME && console.time("markupPack"); @@ -217,7 +276,10 @@ class FeatureModule { const distanceField = new Int8Array(packCellsNumber); // pack.cells.t 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 features: PackedGraphFeature[] = []; @@ -242,9 +304,15 @@ class FeatureModule { distanceField[neighborId] = this.WATER_COAST; if (!haven[cellId]) defineHaven(cellId); } 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; - 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; } @@ -256,12 +324,25 @@ class FeatureModule { } } - features.push(addFeature({ firstCell, land, border, featureId, totalCells })); - queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell + features.push( + 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({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); // markup pack water + this.markup({ + 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.f = featureIds; @@ -287,34 +368,40 @@ class FeatureModule { if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; if (feature.cells > ISLAND_MIN_SIZE) return "island"; return "isle"; - } + }; const defineOceanGroup = (feature: PackedGraphFeature) => { if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; if (feature.cells > SEA_MIN_SIZE) return "sea"; return "gulf"; - } + }; const defineLakeGroup = (feature: PackedGraphFeature) => { 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.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"; return "freshwater"; - } + }; const defineGroup = (feature: PackedGraphFeature) => { if (feature.type === "island") return defineIslandGroup(feature); if (feature.type === "ocean") return defineOceanGroup(feature); if (feature.type === "lake") return defineLakeGroup(feature); throw new Error(`Markup: unknown feature type ${feature.type}`); - } + }; for (const feature of pack.features) { if (!feature || feature.type === "ocean") continue; diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index a060ecdc..9a6d462a 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -1,14 +1,33 @@ import Alea from "alea"; 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 { - 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; heights: Uint8Array | null = null; blobPower: number = 0; @@ -17,9 +36,8 @@ class HeightmapGenerator { private clearData() { this.heights = null; this.grid = null; - }; + } - private getBlobPower(cells: number): number { const blobPowerMap: Record = { 1000: 0.93, @@ -34,11 +52,11 @@ class HeightmapGenerator { 70000: 0.9955, 80000: 0.996, 90000: 0.9964, - 100000: 0.9973 + 100000: 0.9973, }; return blobPowerMap[cells] || 0.98; } - + private getLinePower(cells: number): number { const linePowerMap: Record = { 1000: 0.75, @@ -53,38 +71,43 @@ class HeightmapGenerator { 70000: 0.88, 80000: 0.91, 90000: 0.92, - 100000: 0.93 + 100000: 0.93, }; - + return linePowerMap[cells] || 0.81; } - + private getPointInRange(range: string, length: number): number | undefined { if (typeof range !== "string") { window.ERROR && console.error("Range should be a string"); return; } - - const min = parseInt(range.split("-")[0]) / 100 || 0; - const max = parseInt(range.split("-")[1]) / 100 || min; + + const min = parseInt(range.split("-")[0], 10) / 100 || 0; + const max = parseInt(range.split("-")[1], 10) / 100 || min; return rand(min * length, max * length); } setGraph(graph: any) { - const {cellsDesired, cells, points} = graph; - this.heights = cells.h ? Uint8Array.from(cells.h) : createTypedArray({maxValue: 100, length: points.length}) as Uint8Array; + const { cellsDesired, cells, points } = graph; + this.heights = cells.h + ? Uint8Array.from(cells.h) + : (createTypedArray({ + maxValue: 100, + length: points.length, + }) as Uint8Array); this.blobPower = this.getBlobPower(cellsDesired); this.linePower = this.getLinePower(cellsDesired); this.grid = graph; - }; - + } + addHill(count: string, height: string, rangeX: string, rangeY: string): void { const addOneHill = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const change = new Uint8Array(this.heights.length); let limit = 0; let start: number; - let h = lim(getNumberInRange(height)); + const h = lim(getNumberInRange(height)); do { const x = this.getPointInRange(rangeX, graphWidth); @@ -106,17 +129,17 @@ class HeightmapGenerator { } this.heights = this.heights.map((h, i) => lim(h + change[i])); - } + }; const desiredHillCount = getNumberInRange(count); for (let i = 0; i < desiredHillCount; i++) { addOneHill(); } - }; + } addPit(count: string, height: string, rangeX: string, rangeY: string): void { const addOnePit = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const used = new Uint8Array(this.heights.length); let limit = 0; let start: number; @@ -138,24 +161,33 @@ class HeightmapGenerator { this.grid.cells.c[q].forEach((c: number) => { if (used[c] || this.heights === null) return; - this.heights[c] = lim(this.heights[c] - h * (Math.random() * 0.2 + 0.9)); + this.heights[c] = lim( + this.heights[c] - h * (Math.random() * 0.2 + 0.9), + ); used[c] = 1; queue.push(c); }); } - } + }; const desiredPitCount = getNumberInRange(count); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } - }; + } - addRange(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { - if(!this.heights || !this.grid) return; + addRange( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { + if (!this.heights || !this.grid) return; const addOneRange = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; // get main ridge const getRange = (cur: number, end: number) => { @@ -180,7 +212,7 @@ class HeightmapGenerator { } return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); @@ -192,32 +224,37 @@ class HeightmapGenerator { let dist = 0; let limit = 0; - let endY; - let endX; + let endY: number; + let endX: number; do { endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50); + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 3) && + limit < 50 + ); startCellId = findGridCell(startX, startY, this.grid); endCellId = findGridCell(endX, endY, this.grid); } - let range = getRange(startCellId as number, endCellId as number); - + const range = getRange(startCellId as number, endCellId as number); // add height to ridge and cells around let queue = range.slice(); let i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; frontier.forEach((i: number) => { - if(!this.heights) return; - this.heights[i] = lim(this.heights[i] + h * (Math.random() * 0.3 + 0.85)); + if (!this.heights) return; + this.heights[i] = lim( + this.heights[i] + h * (Math.random() * 0.3 + 0.85), + ); }); h = h ** this.linePower - 1; if (h < 2) break; @@ -235,31 +272,42 @@ class HeightmapGenerator { range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredRangeCount = getNumberInRange(count); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } - }; + } - addTrough(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { + addTrough( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { 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 range = [cur]; const p = this.grid.points; used[cur] = 1; - + while (cur !== end) { let min = Infinity; this.grid.cells.c[cur].forEach((e: number) => { @@ -275,13 +323,13 @@ class HeightmapGenerator { range.push(cur); used[cur] = 1; } - + return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); - + if (rangeX && rangeY) { // find start and end points let limit = 0; @@ -296,29 +344,34 @@ class HeightmapGenerator { startCellId = findGridCell(startX, startY, this.grid); limit++; } while (this.heights[startCellId] < 20 && limit < 50); - + limit = 0; do { endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50); - + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 2) && + limit < 50 + ); + 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 let queue = range.slice(), - i = 0; + i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; 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; if (h < 2) break; @@ -331,41 +384,62 @@ class HeightmapGenerator { }); }); } - + // generate prominences range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell //debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1); - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredTroughCount = getNumberInRange(count); - for(let i = 0; i < desiredTroughCount; i++) { + for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } - }; - + } + addStrait(width: string, direction = "vertical"): void { - if(!this.heights || !this.grid) return; - const desiredWidth = Math.min(getNumberInRange(width), this.grid.cellsX / 3); + if (!this.heights || !this.grid) return; + const desiredWidth = Math.min( + getNumberInRange(width), + this.grid.cellsX / 3, + ); if (desiredWidth < 1 && P(desiredWidth)) return; const used = new Uint8Array(this.heights.length); const vert = direction === "vertical"; - const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5; - const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); + const startX = vert + ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) + : 5; + const startY = vert + ? 5 + : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); const endX = vert - ? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2) + ? Math.floor( + graphWidth - + startX - + graphWidth * 0.1 + + Math.random() * graphWidth * 0.2, + ) : graphWidth - 5; const endY = vert ? graphHeight - 5 - : Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2); + : Math.floor( + graphHeight - + startY - + graphHeight * 0.1 + + Math.random() * graphHeight * 0.2, + ); const start = findGridCell(startX, startY, this.grid); const end = findGridCell(endX, endY, this.grid); @@ -388,14 +462,13 @@ class HeightmapGenerator { } return range; - } + }; let range = getRange(start, end); const query: number[] = []; - 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; range.forEach((r: number) => { this.grid.cells.c[r].forEach((e: number) => { @@ -408,15 +481,17 @@ class HeightmapGenerator { }); range = query.slice(); } - }; + } modify(range: string, add: number, mult: number, power?: number): void { - if(!this.heights) return; - const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; - const max = range === "land" || range === "all" ? 100 : +range.split("-")[1]; + if (!this.heights) return; + const min = + range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; + const max = + range === "land" || range === "all" ? 100 : +range.split("-")[1]; const isLand = min === 20; - this.heights = this.heights.map(h => { + this.heights = this.heights.map((h) => { if (h < min || h > max) return h; 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; return lim(h); }); - }; + } 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) => { 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; return lim((h * (fr - 1) + (mean(a) as number) + add) / fr); }); - }; + } mask(power = 1): void { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const fr = power ? Math.abs(power) : 1; this.heights = this.heights.map((h, i) => { @@ -449,17 +526,17 @@ class HeightmapGenerator { const masked = h * distance; return lim((h * (fr - 1) + masked) / fr); }); - }; + } invert(count: number, axes: string): void { if (!P(count) || !this.heights || !this.grid) return; const invertX = axes !== "y"; const invertY = axes !== "x"; - const {cellsX, cellsY} = this.grid; + const { cellsX, cellsY } = this.grid; const inverted = this.heights.map((_h: number, i: number) => { - if(!this.heights) return 0; + if (!this.heights) return 0; const x = i % cellsX; const y = Math.floor(i / cellsX); @@ -470,66 +547,104 @@ class HeightmapGenerator { }); this.heights = inverted; - }; + } addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void { - if (tool === "Hill") return this.addHill(a2, a3, a4, a5); - if (tool === "Pit") return this.addPit(a2, a3, a4, a5); - if (tool === "Range") return this.addRange(a2, a3, a4, a5); - if (tool === "Trough") return this.addTrough(a2, a3, a4, a5); - if (tool === "Strait") return this.addStrait(a2, a3); - if (tool === "Mask") return this.mask(+a2); - if (tool === "Invert") return this.invert(+a2, a3); - if (tool === "Add") return this.modify(a3, +a2, 1); - if (tool === "Multiply") return this.modify(a3, 0, +a2); - if (tool === "Smooth") return this.smooth(+a2); + if (tool === "Hill") { + this.addHill(a2, a3, a4, a5); + return; + } + if (tool === "Pit") { + this.addPit(a2, a3, a4, a5); + return; + } + if (tool === "Range") { + 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 { TIME && console.time("defineHeightmap"); const id = (byId("templateInput")! as HTMLInputElement).value; - Math.random = Alea(seed); 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"); this.clearData(); return heights as Uint8Array; } - fromTemplate(graph: any, id: string): Uint8Array | null { + fromTemplate(graph: any, id: string): Uint8Array | null { const templateString = heightmapTemplates[id]?.template || ""; const steps = templateString.split("\n"); - if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`); + if (!steps.length) + throw new Error( + `Heightmap template: no steps. Template: ${id}. Steps: ${steps}`, + ); this.setGraph(graph); for (const step of steps) { const elements = step.trim().split(" "); - if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`); - this.addStep(...elements as [Tool, string, string, string, string]); + if (elements.length < 2) + throw new Error( + `Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`, + ); + this.addStep(...(elements as [Tool, string, string, string, string])); } return this.heights; - }; + } private getHeightsFromImageData(imageData: Uint8ClampedArray): void { - if(!this.heights) return; + if (!this.heights) return; for (let i = 0; i < this.heights.length; i++) { const lightness = imageData[i * 4] / 255; - const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; + const powered = + lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; this.heights[i] = minmax(Math.floor(powered * 100), 0, 100); } } fromPrecreated(graph: any, id: string): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { // create canvas where 1px corresponds to a cell const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; - const {cellsX, cellsY} = graph; + const { cellsX, cellsY } = graph; canvas.width = cellsX; canvas.height = cellsY; @@ -537,7 +652,7 @@ class HeightmapGenerator { const img = new Image(); img.src = `./heightmaps/${id}.png`; img.onload = () => { - if(!ctx) { + if (!ctx) { throw new Error("Could not get canvas context"); } this.heights = this.heights || new Uint8Array(cellsX * cellsY); @@ -550,11 +665,11 @@ class HeightmapGenerator { resolve(this.heights); }; }); - }; + } getHeights() { return this.heights; } } -window.HeightmapGenerator = new HeightmapGenerator(); \ No newline at end of file +window.HeightmapGenerator = new HeightmapModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index c57e7688..9db7aaef 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -5,4 +5,4 @@ import "./names-generator"; import "./ocean-layers"; import "./lakes"; import "./river-generator"; -import "./biomes" +import "./biomes"; diff --git a/src/modules/lakes.ts b/src/modules/lakes.ts index 6fa381ac..6a95d0af 100644 --- a/src/modules/lakes.ts +++ b/src/modules/lakes.ts @@ -1,7 +1,6 @@ -import { PackedGraphFeature } from "./features"; -import { min, mean } from "d3"; -import { byId, -rn } from "../utils"; +import { mean, min } from "d3"; +import { byId, rn } from "../utils"; +import type { PackedGraphFeature } from "./features"; declare global { var Lakes: LakesModule; @@ -12,24 +11,25 @@ export class LakesModule { getHeight(feature: PackedGraphFeature) { 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); - }; + } defineNames() { pack.features.forEach((feature: PackedGraphFeature) => { if (feature.type !== "lake") return; feature.name = this.getName(feature); }); - }; + } getName(feature: PackedGraphFeature): string { const landCell = feature.shoreline[0]; const culture = pack.cells.culture[landCell]; return Names.getCulture(culture); - }; + } - cleanupLakeData = function () { + cleanupLakeData = () => { for (const feature of pack.features) { if (feature.type !== "lake") continue; delete feature.river; @@ -38,39 +38,50 @@ export class LakesModule { delete feature.closed; 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; 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; } }; defineClimateData(heights: number[] | Uint8Array) { - const {cells, features} = pack; + const { cells, features } = pack; const lakeOutCells = new Uint16Array(cells.i.length); - + 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) => { 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 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); - } + }; const getLowestShoreCell = (lake: PackedGraphFeature) => { return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; - } + }; - features.forEach(feature => { + features.forEach((feature) => { if (feature.type !== "lake") return; feature.flux = getFlux(feature); feature.temp = getLakeTemp(feature); @@ -82,14 +93,16 @@ export class LakesModule { }); return lakeOutCells; - }; + } // check if lake can be potentially open (not in deep depression) detectCloseLakes(h: number[] | Uint8Array) { - const {cells} = pack; - const ELEVATION_LIMIT = +(byId("lakeElevationLimitOutput") as HTMLInputElement)?.value; + const { cells } = pack; + const ELEVATION_LIMIT = +( + byId("lakeElevationLimitOutput") as HTMLInputElement + )?.value; - pack.features.forEach(feature => { + pack.features.forEach((feature) => { if (feature.type !== "lake") return; delete feature.closed; @@ -100,7 +113,9 @@ export class LakesModule { } 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 checked = []; checked[lowestShorelineCell] = true; @@ -114,7 +129,8 @@ export class LakesModule { if (h[neibCellId] < 20) { 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; @@ -124,7 +140,7 @@ export class LakesModule { feature.closed = isDeep; }); - }; + } } -window.Lakes = new LakesModule(); \ No newline at end of file +window.Lakes = new LakesModule(); diff --git a/src/modules/ocean-layers.ts b/src/modules/ocean-layers.ts index 11467ea5..a18b844a 100644 --- a/src/modules/ocean-layers.ts +++ b/src/modules/ocean-layers.ts @@ -1,6 +1,6 @@ -import { line, curveBasisClosed } from 'd3'; -import type { Selection } from 'd3'; -import { clipPoly,P,rn,round } from '../utils'; +import type { Selection } from "d3"; +import { curveBasisClosed, line } from "d3"; +import { clipPoly, P, rn, round } from "../utils"; declare global { var OceanLayers: typeof OceanModule.prototype.draw; @@ -13,7 +13,6 @@ class OceanModule { private lineGen = line().curve(curveBasisClosed); private oceanLayers: Selection; - constructor(oceanLayers: Selection) { this.oceanLayers = oceanLayers; } @@ -35,11 +34,17 @@ class OceanModule { // connect vertices to chain connectVertices(start: number, t: number) { 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 chain.push(current); // add current vertex to sequence 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 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; @@ -58,9 +63,16 @@ class OceanModule { // find eligible cell vertex to start path detection 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 - return this.cells.v[i][this.cells.c[i].findIndex((c: number)=> this.cells.t[c] < t || !this.cells.t[c])]; - } + 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 + return this.cells.v[i][ + this.cells.c[i].findIndex( + (c: number) => this.cells.t[c] < t || !this.cells.t[c], + ) + ]; + } draw() { const outline = this.oceanLayers.attr("layers"); @@ -69,8 +81,11 @@ class OceanModule { this.cells = grid.cells; this.pointsN = grid.cells.i.length; 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 opacity = rn(0.4 / limits.length, 2); this.used = new Uint8Array(this.pointsN); // to detect already passed cells @@ -85,22 +100,33 @@ class OceanModule { const chain = this.connectVertices(start, t); // vertices chain to form a path if (chain.length < 4) continue; const relax = 1 + t * -2; // select only n-th point - const relaxed = chain.filter((v, i) => !(i % relax) || 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; - + const points = clipPoly( - relaxed.map(v => this.vertices.p[v]), + relaxed.map((v) => this.vertices.p[v]), graphWidth, graphHeight, - 1 + 1, ); chains.push([t, points]); } for (const t of limits) { const layer = chains.filter((c: [number, any[]]) => c[0] === t); - let path = layer.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); + const path = layer + .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"); diff --git a/src/modules/river-generator.ts b/src/modules/river-generator.ts index 55cedaa1..a953aa51 100644 --- a/src/modules/river-generator.ts +++ b/src/modules/river-generator.ts @@ -1,8 +1,6 @@ import Alea from "alea"; -import { each, rn, round, rw} from "../utils"; -import { curveBasis, line, mean, min, sum, curveCatmullRom } from "d3"; - - +import { curveBasis, curveCatmullRom, line, mean, min, sum } from "d3"; +import { each, rn, round, rw } from "../utils"; declare global { var Rivers: RiverModule; @@ -29,18 +27,20 @@ class RiverModule { private MAX_FLUX_WIDTH = 1; private LENGTH_FACTOR = 200; 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 lineGen = line().curve(curveBasis) + private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map( + (n) => n / this.LENGTH_FACTOR, + ); + private lineGen = line().curve(curveBasis); riverTypes = { main: { - big: {River: 1}, - small: {Creek: 9, River: 3, Brook: 3, Stream: 1} + big: { River: 1 }, + small: { Creek: 9, River: 3, Brook: 3, Stream: 1 }, }, fork: { - big: {Fork: 1}, - small: {Branch: 1} - } + big: { Fork: 1 }, + small: { Branch: 1 }, + }, }; smallLength: number | null = null; @@ -48,10 +48,10 @@ class RiverModule { generate(allowErosion = true) { TIME && console.time("generateRivers"); Math.random = Alea(seed); - const {cells, features} = pack; + const { cells, features } = pack; - const riversData: {[riverId: number]: number[]} = {}; - const riverParents: {[key: number]: number} = {}; + const riversData: { [riverId: number]: number[] } = {}; + const riverParents: { [key: number]: number } = {}; const addCellToRiver = (cellId: number, riverId: number) => { if (!riversData[riverId]) riversData[riverId] = [cellId]; @@ -60,26 +60,36 @@ class RiverModule { const drainWater = () => { 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 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); - land.forEach(function (i: number) { + for (const i of land) { cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation // create lake outlet if lake is not in deep depression and flux > evaporation const lakes = lakeOutCells[i] - ? features.filter((feature: any) => i === feature.outCell && feature.flux > feature.evaporation) + ? features.filter( + (feature: any) => + i === feature.outCell && feature.flux > feature.evaporation, + ) : []; 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 // allow chain lakes to retain identity 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) { cells.r[lakeCell] = lake.river as number; @@ -105,12 +115,18 @@ class RiverModule { } // 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) let min = null; 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]; } else if (cells.haven[i]) { min = cells.haven[i]; @@ -119,7 +135,7 @@ class RiverModule { } // cells is depressed - if (h[i] <= h[min]) return; + if (h[i] <= h[min]) continue; // debug // .append("line") @@ -133,7 +149,7 @@ class RiverModule { if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) { // flux is too small to operate as a river if (h[min] >= 20) cells.fl[min] += cells.fl[i]; - return; + continue; } // proclaim a new river @@ -144,8 +160,8 @@ class RiverModule { } flowDown(min, cells.fl[i], cells.r[i]); - }); - } + } + }; const flowDown = (toCell: number, fromFlux: number, river: number) => { const toFlux = cells.fl[toCell] - cells.conf[toCell]; @@ -167,7 +183,10 @@ class RiverModule { // pour water to the water body const waterBody = features[cells.f[toCell]]; if (waterBody.type === "lake") { - if (!waterBody.river || fromFlux > (waterBody.enteringFlux as number)) { + if ( + !waterBody.river || + fromFlux > (waterBody.enteringFlux as number) + ) { waterBody.river = river; waterBody.enteringFlux = fromFlux; } @@ -181,7 +200,7 @@ class RiverModule { } addCellToRiver(toCell, river); - } + }; const defineRivers = () => { // re-initialize rivers and confluence arrays @@ -189,7 +208,10 @@ class RiverModule { cells.conf = new Uint16Array(cells.i.length); 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; for (const key in riversData) { @@ -209,7 +231,10 @@ class RiverModule { const mouth = riverCells[riverCells.length - 2]; 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 discharge = cells.fl[mouth]; // m3 in second const length = this.getApproximateLength(meanderedPoints); @@ -219,8 +244,8 @@ class RiverModule { flux: discharge, pointIndex: meanderedPoints.length, widthFactor, - startingWidth: sourceWidth - }) + startingWidth: sourceWidth, + }), ); pack.rivers.push({ @@ -233,10 +258,10 @@ class RiverModule { widthFactor, sourceWidth, parent, - cells: riverCells + cells: riverCells, } as River); } - } + }; const downcutRivers = () => { const MAX_DOWNCUT = 5; @@ -245,14 +270,18 @@ class RiverModule { if (cells.h[i] < 35) continue; // don't donwcut lowlands if (!cells.fl[i]) continue; - const higherCells = cells.c[i].filter((c: number) => cells.h[c] > cells.h[i]); - const higherFlux = higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / higherCells.length; + const higherCells = cells.c[i].filter( + (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; const downcut = Math.floor(cells.fl[i] / higherFlux); if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT); } - } + }; const calculateConfluenceFlux = () => { for (const i of cells.i) { @@ -262,9 +291,13 @@ class RiverModule { .filter((c: number) => cells.r[c] && h[c] > h[i]) .map((c: number) => cells.fl[c]) .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.r = new Uint16Array(cells.i.length); // rivers array @@ -286,20 +319,28 @@ class RiverModule { } TIME && console.timeEnd("generateRivers"); - }; + } 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) => { 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) resolveDepressions(h: number[]) { - const {cells, features} = pack; - const maxIterations = +(document.getElementById("resolveDepressionsStepsOutput") as HTMLInputElement)?.value; + const { cells, features } = pack; + const maxIterations = +( + document.getElementById( + "resolveDepressionsStepsOutput", + ) as HTMLInputElement + )?.value; const checkLakeMaxIteration = maxIterations * 0.85; const elevateLakeMaxIteration = maxIterations * 0.75; @@ -312,7 +353,11 @@ class RiverModule { const progress = []; let depressions = Infinity; 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) { // bad progress, abort and set heights back h = this.alterHeights(); @@ -329,8 +374,11 @@ class RiverModule { if (minHeight >= 100 || l.height > minHeight) continue; if (iteration > elevateLakeMaxIteration) { - l.shoreline.forEach((i: number) => (h[i] = cells.h[i])); - l.height = (min(l.shoreline.map((s: number) => h[s])) as number) - 1; + l.shoreline.forEach((i: number) => { + h[i] = cells.h[i]; + }); + l.height = + (min(l.shoreline.map((s: number) => h[s])) as number) - 1; l.closed = true; continue; } @@ -341,7 +389,9 @@ class RiverModule { } 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; depressions++; @@ -352,11 +402,19 @@ class RiverModule { 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][] { - const {fl, h} = pack.cells; + addMeandering( + riverCells: number[], + riverPoints = null, + meandering = 0.5, + ): [number, number, number][] { + const { fl, h } = pack.cells; const meandered = []; const lastStep = riverCells.length - 1; const points = this.getRiverPoints(riverCells, riverPoints); @@ -382,7 +440,8 @@ class RiverModule { const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells 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 sinMeander = Math.sin(angle) * meander; const cosMeander = Math.cos(angle) * meander; @@ -403,17 +462,17 @@ class RiverModule { } return meandered as [number, number, number][]; - }; + } getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) { if (riverPoints) return riverPoints; - const {p} = pack.cells; + const { p } = pack.cells; return riverCells.map((cell, i) => { if (cell === -1) return this.getBorderPoint(riverCells[i - 1]); return p[cell]; }); - }; + } getBorderPoint(i: number) { const [x, y] = pack.cells.p[i]; @@ -422,22 +481,42 @@ class RiverModule { else if (min === graphHeight - y) return [x, graphHeight]; else if (min === x) return [0, 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; - const fluxWidth = Math.min(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); + const fluxWidth = Math.min( + 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; - }; + } getSourceWidth(flux: number) { 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) - 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)); const riverPointsLeft: [number, number][] = []; const riverPointsRight: [number, number][] = []; @@ -449,7 +528,12 @@ class RiverModule { const [x2, y2] = points[pointIndex + 1] || points[pointIndex]; 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 sinOffset = Math.sin(angle) * offset; const cosOffset = Math.cos(angle) * offset; @@ -463,7 +547,7 @@ class RiverModule { left = left.substring(left.indexOf("C")); return round(right + left, 1); - }; + } specify() { const rivers = pack.rivers; @@ -474,57 +558,69 @@ class RiverModule { river.name = this.getName(river.mouth); river.type = this.getType(river); } - }; + } getName(cell: number) { return Names.getCulture(pack.cells.culture[cell]); - }; + } - getType({i, length, parent}: River) { + getType({ i, length, parent }: River) { if (this.smallLength === null) { 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 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][]) { - 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); - }; + } // 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 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(id: number) { const cells = pack.cells; - const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i); - riversToRemove.forEach(r => rivers.select("#river" + r).remove()); + const riversToRemove = pack.rivers + .filter((r) => r.i === id || r.parent === id || r.basin === id) + .map((r) => r.i); + riversToRemove.forEach((r) => { + rivers.select(`#river${r}`).remove(); + }); cells.r.forEach((r, i) => { if (!r || !riversToRemove.includes(r)) return; cells.r[i] = 0; cells.fl[i] = grid.cells.prec[cells.g[i]]; cells.conf[i] = 0; }); - pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i)); - }; + pack.rivers = pack.rivers.filter((r) => !riversToRemove.includes(r.i)); + } 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; return this.getBasin(parent); - }; + } - getNextId(rivers: {i: number}[]) { - return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1; - }; + getNextId(rivers: { i: number }[]) { + return rivers.length ? Math.max(...rivers.map((r) => r.i)) + 1 : 1; + } } -window.Rivers = new RiverModule() \ No newline at end of file +window.Rivers = new RiverModule(); diff --git a/src/modules/voronoi.ts b/src/modules/voronoi.ts index 55ac77ab..adceaaa6 100644 --- a/src/modules/voronoi.ts +++ b/src/modules/voronoi.ts @@ -1,6 +1,11 @@ -import Delaunator from "delaunator"; -export type Vertices = { p: Point[], v: number[][], c: number[][] }; -export type Cells = { v: number[][], c: number[][], b: number[], i: Uint32Array } ; +import type Delaunator from "delaunator"; +export type Vertices = { p: Point[]; v: number[][]; c: number[][] }; +export type Cells = { + v: number[][]; + c: number[][]; + b: number[]; + i: Uint32Array; +}; export type Point = [number, number]; /** @@ -11,36 +16,41 @@ export type Point = [number, number]; * @param {number} pointsN The number of points. */ export class Voronoi { - delaunay: Delaunator> + delaunay: Delaunator>; points: Point[]; pointsN: number; cells: Cells = { v: [], c: [], b: [], i: new Uint32Array() }; // voronoi cells: v = cell vertices, c = adjacent cells, b = near-border cell, i = cell indexes; vertices: Vertices = { p: [], v: [], c: [] }; // cells vertices: p = vertex coordinates, v = neighboring vertices, c = adjacent cells - - constructor(delaunay: Delaunator>, points: Point[], pointsN: number) { + + constructor( + delaunay: Delaunator>, + points: Point[], + pointsN: number, + ) { this.delaunay = delaunay; this.points = points; this.pointsN = pointsN; - this.vertices + this.vertices; // Half-edges are the indices into the delaunator outputs: // delaunay.triangles[e] gives the point ID where the half-edge starts // delaunay.halfedges[e] returns either the opposite half-edge in the adjacent triangle, or -1 if there's not an adjacent triangle. for (let e = 0; e < this.delaunay.triangles.length; e++) { - const p = this.delaunay.triangles[this.nextHalfedge(e)]; if (p < this.pointsN && !this.cells.c[p]) { const edges = this.edgesAroundPoint(e); - this.cells.v[p] = edges.map(e => this.triangleOfEdge(e)); // cell: adjacent vertex - this.cells.c[p] = edges.map(e => this.delaunay.triangles[e]).filter(c => c < this.pointsN); // cell: adjacent valid cells - this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border + this.cells.v[p] = edges.map((e) => this.triangleOfEdge(e)); // cell: adjacent vertex + this.cells.c[p] = edges + .map((e) => this.delaunay.triangles[e]) + .filter((c) => c < this.pointsN); // cell: adjacent valid cells + this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border } const t = this.triangleOfEdge(e); if (!this.vertices.p[t]) { - this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates + this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates this.vertices.v[t] = this.trianglesAdjacentToTriangle(t); // vertex: adjacent vertices - this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells + this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells } } } @@ -51,7 +61,9 @@ export class Voronoi { * @returns {[number, number, number]} The IDs of the points comprising the given triangle. */ private pointsOfTriangle(triangleIndex: number): [number, number, number] { - return this.edgesOfTriangle(triangleIndex).map(edge => this.delaunay.triangles[edge]) as [number, number, number]; + 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. */ private trianglesAdjacentToTriangle(triangleIndex: number): number[] { - let triangles = []; - for (let edge of this.edgesOfTriangle(triangleIndex)) { - let opposite = this.delaunay.halfedges[edge]; + const triangles = []; + for (const edge of this.edgesOfTriangle(triangleIndex)) { + const opposite = this.delaunay.halfedges[edge]; triangles.push(this.triangleOfEdge(opposite)); } return triangles; @@ -90,7 +102,9 @@ export class Voronoi { * @returns {[number, number]} The coordinates of the triangle's circumcenter. */ private triangleCenter(triangleIndex: number): Point { - let vertices = this.pointsOfTriangle(triangleIndex).map(p => this.points[p]); + const vertices = this.pointsOfTriangle(triangleIndex).map( + (p) => this.points[p], + ); return this.circumcenter(vertices[0], vertices[1], vertices[2]); } @@ -99,21 +113,27 @@ export class Voronoi { * @param {number} triangleIndex The index of the triangle * @returns {[number, number, number]} The edges of the triangle. */ - private edgesOfTriangle(triangleIndex: number): [number, number, number] { return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; } + private edgesOfTriangle(triangleIndex: number): [number, number, number] { + return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; + } /** * Enables lookup of a triangle, given one of the half-edges of that triangle. Taken from {@link https://mapbox.github.io/delaunator/#edge-and-triangle| the Delaunator docs.} * @param {number} e The index of the edge * @returns {number} The index of the triangle */ - private triangleOfEdge(e: number): number { return Math.floor(e / 3); } + private triangleOfEdge(e: number): number { + return Math.floor(e / 3); + } /** * Moves to the next half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.} * @param {number} e The index of the current half edge * @returns {number} The index of the next half edge */ - private nextHalfedge(e: number): number { return (e % 3 === 2) ? e - 2 : e + 1; } + 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.} @@ -138,8 +158,8 @@ export class Voronoi { const cd = cx * cx + cy * cy; const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); return [ - Math.floor(1 / D * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), - Math.floor(1 / D * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))) + 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))), ]; } -} \ No newline at end of file +} diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 193274b0..9aecef43 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,8 +1,14 @@ import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; - -type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array; +type TypedArray = + | Uint8Array + | Uint16Array + | Uint32Array + | Int8Array + | Int16Array + | Float32Array + | Float64Array; export interface PackedGraph { cells: { @@ -33,5 +39,4 @@ export interface PackedGraph { }; rivers: River[]; features: PackedGraphFeature[]; - cultures: any[]; -} \ No newline at end of file +} diff --git a/src/types/global.ts b/src/types/global.ts index a7558300..3db8615d 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -1,6 +1,6 @@ import type { Selection } from 'd3'; -import { PackedGraph } from "./PackedGraph"; -import { NameBase } from '../modules/names-generator'; +import type { PackedGraph } from "./PackedGraph"; +import type { NameBase } from '../modules/names-generator'; declare global { var seed: string; @@ -8,7 +8,6 @@ declare global { var grid: any; var graphHeight: number; var graphWidth: number; - var TIME: boolean; var WARN: boolean; var ERROR: boolean; @@ -16,6 +15,7 @@ declare global { var heightmapTemplates: any; var nameBases: NameBase[]; + var Names: any; var pointsInput: HTMLInputElement; var heightExponentInput: HTMLInputElement; var mapName: HTMLInputElement; @@ -36,4 +36,4 @@ declare global { var tip: (message: string, autoHide?: boolean, type?: "info" | "warning" | "error") => void; var locked: (settingId: string) => boolean; var unlock: (settingId: string) => void; -} \ No newline at end of file +} diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts index ad2f9486..add587d8 100644 --- a/src/utils/arrayUtils.ts +++ b/src/utils/arrayUtils.ts @@ -5,7 +5,7 @@ */ export const last = (array: T[]): T => { return array[array.length - 1]; -} +}; /** * Get unique elements from an array @@ -14,7 +14,7 @@ export const last = (array: T[]): T => { */ export const unique = (array: T[]): T[] => { return [...new Set(array)]; -} +}; /** * Deep copy an object or array @@ -24,12 +24,15 @@ export const unique = (array: T[]): T[] => { export const deepCopy = (obj: T): T => { const id = (x: T): T => x; const dcTArray = (a: T[]): T[] => a.map(id); - const dcObject = (x: object): object => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); - const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x); + const dcObject = (x: object): object => + Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); + const dcAny = (x: any): any => + x instanceof Object ? (cf.get(x.constructor) || id)(x) : x; // don't map keys, probably this is what we would expect - const dcMapCore = (m: Map): [any, any][] => [...m.entries()].map(([k, v]) => [k, dcAny(v)]); + const dcMapCore = (m: Map): [any, any][] => + [...m.entries()].map(([k, v]) => [k, dcAny(v)]); - const cf: Map any> = new Map any>([ + const cf: Map any> = new Map any>([ [Int8Array, dcTArray], [Uint8Array, dcTArray], [Uint8ClampedArray, dcTArray], @@ -41,17 +44,17 @@ export const deepCopy = (obj: T): T => { [Float64Array, dcTArray], [BigInt64Array, dcTArray], [BigUint64Array, dcTArray], - [Map, m => new Map(dcMapCore(m))], - [WeakMap, m => new WeakMap(dcMapCore(m))], - [Array, a => a.map(dcAny)], - [Set, s => [...s.values()].map(dcAny)], - [Date, d => new Date(d.getTime())], - [Object, dcObject] + [Map, (m) => new Map(dcMapCore(m))], + [WeakMap, (m) => new WeakMap(dcMapCore(m))], + [Array, (a) => a.map(dcAny)], + [Set, (s) => [...s.values()].map(dcAny)], + [Date, (d) => new Date(d.getTime())], + [Object, dcObject], // ... extend here to implement their custom deep copy ]); return dcAny(obj); -} +}; /** * Get the appropriate typed array constructor based on the maximum value @@ -60,15 +63,17 @@ export const deepCopy = (obj: T): T => { */ export const getTypedArray = (maxValue: number) => { console.assert( - Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, - `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}` + Number.isInteger(maxValue) && + maxValue >= 0 && + maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, + `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}`, ); if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT8_MAX) return Uint8Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array; return Uint32Array; -} +}; /** * Create a typed array based on the maximum value and length or from an existing array @@ -78,18 +83,26 @@ export const getTypedArray = (maxValue: number) => { * @param {Array} [options.from] - An optional array to create the typed array from * @returns The created typed array */ -export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike}): Uint8Array | Uint16Array | Uint32Array => { +export const createTypedArray = ({ + maxValue, + length, + from, +}: { + maxValue: number; + length: number; + from?: ArrayLike; +}): Uint8Array | Uint16Array | Uint32Array => { const typedArray = getTypedArray(maxValue); if (!from) return new typedArray(length); return typedArray.from(from); -} +}; // typed arrays max values export const TYPED_ARRAY_MAX_VALUES = { INT8_MAX: 127, UINT8_MAX: 255, UINT16_MAX: 65535, - UINT32_MAX: 4294967295 + UINT32_MAX: 4294967295, }; declare global { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index e64636fc..9a2d26f5 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -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 @@ -8,14 +16,16 @@ import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequentia export const toHEX = (rgba: string): string => { if (rgba.charAt(0) === "#") return rgba; - const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); + const matches = rgba.match( + /^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i, + ); return matches && matches.length === 4 ? "#" + - ("0" + parseInt(matches[1], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[2], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[3], 10).toString(16)).slice(-2) + `0${parseInt(matches[1], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[2], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[3], 10).toString(16)}`.slice(-2) : ""; -} +}; /** Predefined set of 12 distinct colors */ export const C_12 = [ @@ -30,33 +40,39 @@ export const C_12 = [ "#ccebc5", "#ffed6f", "#8dd3c7", - "#eb8de7" + "#eb8de7", ]; -/** +/** * Get an array of distinct colors * Uses shuffler with current Math.random to ensure seeded randomness works * @param {number} count - The count of colors to generate * @returns {string[]} - The array of HEX color strings -*/ + */ export const getColors = (count: number): string[] => { const scaleRainbow = scaleSequential(interpolateRainbow); // Use shuffler() to create a shuffle function that uses the current Math.random const shuffle = shuffler(() => Math.random()); 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"); -} +}; /** * Get a random color in HEX format * @returns {string} - The HEX color string */ export const getRandomColor = (): string => { - const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor; + const colorFromRainbow: RGBColor = color( + scaleSequential(interpolateRainbow)(Math.random()), + ) as RGBColor; return colorFromRainbow.formatHex(); -} +}; /** * Get a mixed color by blending a given color with a random color @@ -65,11 +81,17 @@ export const getRandomColor = (): string => { * @param {number} bright - The brightness adjustment * @returns {string} - The mixed HEX color string */ -export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): string => { +export const getMixedColor = ( + colorToMix: string, + mix = 0.2, + bright = 0.3, +): string => { const c = colorToMix && colorToMix[0] === "#" ? colorToMix : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one - const mixedColor: RGBColor = color(interpolate(c, getRandomColor())(mix)) as RGBColor; + const mixedColor: RGBColor = color( + interpolate(c, getRandomColor())(mix), + ) as RGBColor; return mixedColor.brighter(bright).formatHex(); -} +}; declare global { interface Window { @@ -78,5 +100,5 @@ declare global { getRandomColor: typeof getRandomColor; getMixedColor: typeof getMixedColor; C_12: typeof C_12; - } + } } diff --git a/src/utils/commonUtils.test.ts b/src/utils/commonUtils.test.ts new file mode 100644 index 00000000..c5ed8f7e --- /dev/null +++ b/src/utils/commonUtils.test.ts @@ -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 + }); +}); diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index 24f7501c..6808eb11 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -1,7 +1,7 @@ -import { distanceSquared } from "./functionUtils"; -import { rand } from "./probabilityUtils"; -import { rn } from "./numberUtils"; import { last } from "./arrayUtils"; +import { distanceSquared } from "./functionUtils"; +import { rn } from "./numberUtils"; +import { rand } from "./probabilityUtils"; /** * Clip polygon points to graph boundaries @@ -11,15 +11,20 @@ import { last } from "./arrayUtils"; * @param secure - Secure clipping to avoid edge artifacts * @returns Clipped polygon points */ -export const clipPoly = (points: [number, number][], graphWidth?: number, graphHeight?: number, secure: number = 0) => { +export const clipPoly = ( + points: [number, number][], + graphWidth?: number, + graphHeight?: number, + secure: number = 0, +) => { if (points.length < 2) return points; - if (points.some(point => point === undefined)) { + if (points.some((point) => point === undefined)) { window.ERROR && console.error("Undefined point in clipPoly", points); return points; } return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure); -} +}; /** * Get segment of any point on polyline @@ -28,7 +33,11 @@ export const clipPoly = (points: [number, number][], graphWidth?: number, graphH * @param step - Step size for segment search (default is 10) * @returns The segment ID (1-indexed) */ -export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => { +export const getSegmentId = ( + points: [number, number][], + point: [number, number], + step: number = 10, +): number => { if (points.length === 2) return 1; let minSegment = 1; @@ -55,7 +64,7 @@ export const getSegmentId = (points: [number, number][], point: [number, number] } return minSegment; -} +}; /** * 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 * @returns The debounced function */ -export const debounce = any>(func: T, ms: number) => { +export const debounce = any>( + func: T, + ms: number, +) => { let isCooldown = false; return function (this: any, ...args: Parameters) { if (isCooldown) return; func.apply(this, args); isCooldown = true; - setTimeout(() => (isCooldown = false), ms); + setTimeout(() => { + isCooldown = false; + }, ms); }; -} +}; /** * Creates a throttled function that only invokes func at most once every ms milliseconds @@ -80,7 +94,10 @@ export const debounce = any>(func: T, ms: number) * @param ms - The number of milliseconds to throttle invocations to * @returns The throttled function */ -export const throttle = any>(func: T, ms: number) => { +export const throttle = any>( + func: T, + ms: number, +) => { let isThrottled = false; let savedArgs: any[] | null = null; let savedThis: any = null; @@ -95,7 +112,7 @@ export const throttle = any>(func: T, ms: number) func.apply(this, args); isThrottled = true; - setTimeout(function () { + setTimeout(() => { isThrottled = false; if (savedArgs) { wrapper.apply(savedThis, savedArgs as Parameters); @@ -105,7 +122,7 @@ export const throttle = any>(func: T, ms: number) } return wrapper; -} +}; /** * Parse error to get the readable string in Chrome and Firefox @@ -114,23 +131,32 @@ export const throttle = any>(func: T, ms: number) */ export const parseError = (error: Error): string => { const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; - const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack || ""; - const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; - const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + ""); + const errorString = isFirefox + ? `${error.toString()} ${error.stack}` + : error.stack || ""; + const regex = + /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; + const errorNoURL = errorString.replace( + regex, + (url) => `${last(url.split("/"))}`, + ); const errorParsed = errorNoURL.replace(/at /gi, "
  at "); return errorParsed; -} +}; /** * Convert a URL to base64 encoded data * @param url - The URL to convert * @param callback - Callback function that receives the base64 data */ -export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | null) => void): void => { +export const getBase64 = ( + url: string, + callback: (result: string | ArrayBuffer | null) => void, +): void => { const xhr = new XMLHttpRequest(); - xhr.onload = function () { + xhr.onload = () => { const reader = new FileReader(); - reader.onloadend = function () { + reader.onloadend = () => { callback(reader.result); }; reader.readAsDataURL(xhr.response); @@ -138,7 +164,7 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | xhr.open("GET", url); xhr.responseType = "blob"; xhr.send(); -} +}; /** * 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 => { window.open(url, "_blank"); -} +}; /** * Open project wiki-page * @param page - The wiki page name/path to open */ export const wiki = (page: string): void => { - window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank"); -} + window.open( + `https://github.com/Azgaar/Fantasy-Map-Generator/wiki/${page}`, + "_blank", + ); +}; /** * Wrap URL into html a element @@ -164,7 +193,7 @@ export const wiki = (page: string): void => { */ export const link = (URL: string, description: string): string => { return `${description}`; -} +}; /** * 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 => { // meta key is cmd key on MacOs return event.ctrlKey || event.metaKey; -} +}; /** * 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", { year: "numeric", month: "long", - day: "numeric" + day: "numeric", }); -} +}; /** * 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) * @returns Longitude value */ -export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => { - return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals); -} +export const getLongitude = ( + x: number, + mapCoordinates: any, + graphWidth: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, + decimals, + ); +}; /** * Convert y coordinate to latitude @@ -210,9 +247,17 @@ export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, * @param decimals - Number of decimal places (default is 2) * @returns Latitude value */ -export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => { - return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals); -} +export const getLatitude = ( + y: number, + mapCoordinates: any, + graphHeight: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, + decimals, + ); +}; /** * Convert x,y coordinates to longitude,latitude @@ -224,9 +269,19 @@ export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, * @param decimals - Number of decimal places (default is 2) * @returns Array with [longitude, latitude] */ -export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => { - return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)]; -} +export const getCoordinates = ( + x: number, + y: number, + mapCoordinates: any, + graphWidth: number, + graphHeight: number, + decimals: number = 2, +): [number, number] => { + return [ + getLongitude(x, mapCoordinates, graphWidth, decimals), + getLatitude(y, mapCoordinates, graphHeight, decimals), + ]; +}; /** * Prompt options interface @@ -246,22 +301,39 @@ export interface PromptOptions { export const initializePrompt = (): void => { const prompt = document.getElementById("prompt"); if (!prompt) return; - + const form = prompt.querySelector("#promptForm"); if (!form) return; const defaultText = "Please provide an input"; - const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true}; + 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) - 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 promptTextElement = prompt.querySelector("#promptText") as HTMLElement; - + const promptTextElement = prompt.querySelector( + "#promptText", + ) as HTMLElement; + if (!input || !promptTextElement) return; - + promptTextElement.innerHTML = promptText; const type = typeof options.default === "number" ? "number" : "text"; @@ -271,8 +343,8 @@ export const initializePrompt = (): void => { if (options.min !== undefined) input.min = options.min.toString(); if (options.max !== undefined) input.max = options.max.toString(); - input.required = options.required === false ? false : true; - input.placeholder = "type a " + type; + input.required = options.required !== false; + input.placeholder = `type a ${type}`; input.value = options.default.toString(); input.style.width = promptText.length > 10 ? "100%" : "auto"; prompt.style.display = "block"; @@ -285,7 +357,7 @@ export const initializePrompt = (): void => { const v = type === "number" ? +input.value : input.value; if (callback) callback(v); }, - {once: true} + { once: true }, ); }; @@ -295,13 +367,13 @@ export const initializePrompt = (): void => { prompt.style.display = "none"; }); } -} +}; declare global { interface Window { ERROR: boolean; polygonclip: any; - + clipPoly: typeof clipPoly; getSegmentId: typeof getSegmentId; debounce: typeof debounce; @@ -317,4 +389,16 @@ declare global { getLatitude: typeof getLatitude; getCoordinates: typeof getCoordinates; } -} \ No newline at end of file + + // 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; +} diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts index 6b236ebe..dec49390 100644 --- a/src/utils/debugUtils.ts +++ b/src/utils/debugUtils.ts @@ -1,7 +1,7 @@ -import {curveBundle, line, max, min} from "d3"; -import { normalize } from "./numberUtils"; -import { getGridPolygon } from "./graphUtils"; +import { curveBundle, line, max, min } from "d3"; import { C_12 } from "./colorUtils"; +import { getGridPolygon } from "./graphUtils"; +import { normalize } from "./numberUtils"; 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("y", (_d: any, i: number) => packedGraph.cells.p[i][1]) .text((d: any) => d); -} +}; /** * Drawing polygons colored according to data values for debugging purposes * @param {number[]} data - Array of numerical values corresponding to each cell @@ -28,9 +28,11 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => { export const drawPolygons = (data: number[], terrs: any, grid: any): void => { const maximum: number = max(data) as number; const minimum: number = min(data) as number; - const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme")); + 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") @@ -40,7 +42,7 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { .attr("points", (_d: number, i: number) => getGridPolygon(i, grid)) .attr("fill", (d: number) => scheme(d)) .attr("stroke", (d: number) => scheme(d)); -} +}; /** * Drawing route connections for debugging purposes @@ -48,7 +50,10 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { */ export const drawRouteConnections = (packedGraph: any): void => { window.debug.select("#connections").remove(); - const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8); + const routes = window.debug + .append("g") + .attr("id", "connections") + .attr("stroke-width", 0.8); const points = packedGraph.cells.p; const links = packedGraph.cells.routes; @@ -70,7 +75,7 @@ export const drawRouteConnections = (packedGraph: any): void => { .attr("stroke", C_12[routeId % 12]); } } -} +}; /** * 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 {number} options.radius - Radius of the point */ -export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => { - window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color); -} +export const drawPoint = ( + [x, y]: [number, number], + { color = "red", radius = 0.5 }, +): void => { + window.debug + .append("circle") + .attr("cx", x) + .attr("cy", y) + .attr("r", radius) + .attr("fill", color); +}; /** * Drawing a path for debugging purposes @@ -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 {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); window.debug .append("path") @@ -98,17 +114,17 @@ export const drawPath = (points: [number, number][], {color = "red", width = 0.5 .attr("stroke", color) .attr("stroke-width", width) .attr("fill", "none"); -} +}; declare global { interface Window { debug: any; getColorScheme: (name: string) => (t: number) => string; - + drawCellsValue: typeof drawCellsValue; drawPolygons: typeof drawPolygons; drawRouteConnections: typeof drawRouteConnections; drawPoint: typeof drawPoint; drawPath: typeof drawPath; - } -} \ No newline at end of file + } +} diff --git a/src/utils/functionUtils.ts b/src/utils/functionUtils.ts index 5a3d7283..a753c019 100644 --- a/src/utils/functionUtils.ts +++ b/src/utils/functionUtils.ts @@ -4,7 +4,7 @@ * @param {Function} reduce - The reduce function to apply to each group * @param {...Function} keys - The key functions to group by * @returns {Map} - The regrouped and reduced Map - * + * * @example * const data = [ * {category: 'A', type: 'X', value: 10}, @@ -24,11 +24,20 @@ * // '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); -} +}; -const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (values: any[]) => any, keys: ((value: any, index: number, array: any[]) => any)[]) => { +const nest = ( + values: any[], + map: (iterable: Iterable) => any, + reduce: (values: any[]) => any, + keys: ((value: any, index: number, array: any[]) => any)[], +) => { return (function regroup(values, i) { if (i >= keys.length) return reduce(values); const groups = new Map(); @@ -45,7 +54,7 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val } return map(groups); })(values, 0); -} +}; /** * Calculate squared distance between two points @@ -53,12 +62,15 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val * @param {[number, number]} p2 - Second point [x2, y2] * @returns {number} - Squared distance between p1 and p2 */ -export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => { +export const distanceSquared = ( + [x1, y1]: [number, number], + [x2, y2]: [number, number], +) => { return (x1 - x2) ** 2 + (y1 - y2) ** 2; -} +}; declare global { interface Window { rollups: typeof rollups; dist2: typeof distanceSquared; } -} \ No newline at end of file +} diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 274d69f9..83ef0ae5 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -1,10 +1,15 @@ -import Delaunator from "delaunator"; import Alea from "alea"; import { color } from "d3"; -import { byId } from "./shorthands"; -import { rn } from "./numberUtils"; +import Delaunator from "delaunator"; +import { + type Cells, + type Point, + type Vertices, + Voronoi, +} from "../modules/voronoi"; 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 @@ -13,7 +18,11 @@ import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; * @param {number} spacing - The spacing between 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 bSpacing = spacing * 2; const w = width - offset * 2; @@ -23,17 +32,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin const points: Point[] = []; 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]); } 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]); } return points; -} +}; /** * 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 * @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 jittering = radius * 0.9; // max deviation const doubleJittering = jittering * 2; const jitter = () => Math.random() * doubleJittering - jittering; - let points: Point[] = []; + const points: Point[] = []; for (let y = radius; y < height; y += spacing) { for (let x = radius; x < width; x += spacing) { const xj = Math.min(rn(x + jitter(), 2), width); @@ -57,7 +70,7 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[ } } return points; -} +}; /** * 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 * @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"); const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0); 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 points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction - const cellCountY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing); // number of cells in y direction + const cellCountY = Math.floor( + (graphHeight + 0.5 * spacing - 1e-10) / spacing, + ); // number of cells in y direction TIME && console.timeEnd("placePoints"); - return {spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY}; -} - + return { + spacing, + cellsDesired, + boundary, + points, + cellsX: cellCountX, + cellsY: cellCountY, + }; +}; /** * 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 * @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; const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0); if (cellsDesired !== grid.cellsDesired) return true; - const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); - const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing); - const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing); + const newSpacing = rn( + Math.sqrt((graphWidth * graphHeight) / cellsDesired), + 2, + ); + const newCellsX = Math.floor( + (graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing, + ); + const newCellsY = Math.floor( + (graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing, + ); - return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY; -} + return ( + grid.spacing !== newSpacing || + grid.cellsX !== newCellsX || + grid.cellsY !== newCellsY + ); +}; interface Grid { spacing: number; @@ -116,12 +163,27 @@ interface Grid { * Generates a Voronoi grid based on jittered grid points * @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed */ -export const generateGrid = (seed: string, graphWidth: number, graphHeight: number): Grid => { +export const generateGrid = ( + seed: string, + graphWidth: number, + graphHeight: number, +): Grid => { Math.random = Alea(seed); // reset PRNG - const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight); - const {cells, vertices} = calculateVoronoi(points, boundary); - return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed}; -} + const { spacing, cellsDesired, boundary, points, cellsX, cellsY } = + placePoints(graphWidth, graphHeight); + const { cells, vertices } = calculateVoronoi(points, boundary); + return { + spacing, + cellsDesired, + boundary, + points, + cellsX, + cellsY, + cells, + vertices, + seed, + }; +}; /** * Calculates the Voronoi diagram from given points and boundary @@ -129,7 +191,10 @@ export const generateGrid = (seed: string, graphWidth: number, graphHeight: numb * @param {Array} boundary - The boundary points to clip the Voronoi cells * @returns {Object} - An object containing Voronoi cells and vertices */ -export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Cells, vertices: Vertices} => { +export const calculateVoronoi = ( + points: Point[], + boundary: Point[], +): { cells: Cells; vertices: Vertices } => { TIME && console.time("calculateDelaunay"); const allPoints = points.concat(boundary); const delaunay = Delaunator.from(allPoints); @@ -139,12 +204,15 @@ export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Ce const voronoi = new Voronoi(delaunay, allPoints, points.length); const cells = voronoi.cells; - cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i) as Uint32Array; // array of indexes + cells.i = createTypedArray({ + maxValue: points.length, + length: points.length, + }).map((_, i) => i) as Uint32Array; // array of indexes const vertices = voronoi.vertices; TIME && console.timeEnd("calculateVoronoi"); - return {cells, vertices}; -} + return { cells, vertices }; +}; /** * Returns a cell index on a regular square grid based on x and y coordinates @@ -158,9 +226,9 @@ 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(x / grid.spacing, grid.cellsX - 1)) ); -} +}; -/** +/** * return array of cell indexes in radius on a regular square grid * @param {number} x - The x coordinate * @param {number} y - The y coordinate @@ -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 * @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; let r = Math.floor(radius / grid.spacing); 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) { let frontier = c[found[0]]; while (r > 1) { - let cycle = frontier.slice(); + const cycle = frontier.slice(); frontier = []; - cycle.forEach(function (s: number) { - c[s].forEach(function (e: number) { + cycle.forEach((s: number) => { + c[s].forEach((e: number) => { if (found.indexOf(e) !== -1) return; found.push(e); frontier.push(e); @@ -191,7 +264,7 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu } return found; -} +}; /** * 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) * @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; const found = packedGraph.cells.q.find(x, y, radius); return found ? found[2] : undefined; -} +}; /** * 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 * @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) => { t.result = []; - (t.x0 = t.x - radius), (t.y0 = t.y - radius); - (t.x3 = t.x + radius), (t.y3 = t.y + radius); + t.x0 = t.x - radius; + t.y0 = t.y - radius; + t.x3 = t.x + radius; + t.y3 = t.y + radius; t.radius = radius * radius; }; const radiusSearchVisit = (t: any, d2: number) => { t.node.data.scanned = true; if (d2 < t.radius) { - do { + while (t.node) { t.result.push(t.node.data); 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)); radiusSearchInit(t, radius); - var i = 0; - while ((t.q = t.quads.pop())) { - i++; + var _i = 0; + t.q = t.quads.pop(); + 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. - if ( - !(t.node = t.q.node) || - (t.x1 = t.q.x0) > t.x3 || - (t.y1 = t.q.y0) > t.y3 || - (t.x2 = t.q.x1) < t.x0 || - (t.y2 = t.q.y1) < t.y0 - ) + if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) { + t.q = t.quads.pop(); continue; + } // Bisect the current quadrant. if (t.node.length) { 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; t.quads.push( new Quad(t.node[3], xm, ym, t.x2, t.y2), new Quad(t.node[2], t.x1, ym, xm, t.y2), new Quad(t.node[1], xm, t.y1, t.x2, ym), - new Quad(t.node[0], t.x1, t.y1, xm, ym) + new Quad(t.node[0], t.x1, t.y1, xm, ym), ); // Visit the closest quadrant first. - if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { + t.i = (+(y >= ym) << 1) | +(x >= xm); + if (t.i) { t.q = t.quads[t.quads.length - 1]; t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; t.quads[t.quads.length - 1 - t.i] = t.q; @@ -289,14 +390,15 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree // Visit this point. (Visiting coincident points isn't necessary!) else { - var dx = x - +quadtree._x.call(null, t.node.data), - dy = y - +quadtree._y.call(null, t.node.data), - d2 = dx * dx + dy * dy; + dx = x - +quadtree._x.call(null, t.node.data); + dy = y - +quadtree._y.call(null, t.node.data); + d2 = dx * dx + dy * dy; radiusSearchVisit(t, d2); } + t.q = t.quads.pop(); } return t.result; -} +}; /** * 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 * @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 const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); return found.map((r: any) => r[2]); -} +}; /** * 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 */ 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 @@ -328,7 +437,7 @@ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { */ export const getGridPolygon = (i: number, grid: any) => { return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); -} +}; /** * 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) * @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(); 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) { 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]; } @@ -410,7 +527,7 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb */ export const isLand = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] >= 20; -} +}; /** * 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) => { return packedGraph.cells.h[i] < 20; -} - +}; // 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 * @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"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d")!; const imageData = ctx.createImageData(width, height); - const getHeight = (height: number) => (height < 20 ? (renderOcean ? height : 0) : height); + const getHeight = (height: number) => + height < 20 ? (renderOcean ? height : 0) : height; for (let i = 0; i < heights.length; i++) { const colorScheme = scheme(1 - getHeight(heights[i]) / 100); - const {r, g, b} = color(colorScheme)!.rgb(); + const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 }; const n = i * 4; imageData.data[n] = r; @@ -455,12 +584,11 @@ export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heig ctx.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); -} +}; declare global { var TIME: boolean; interface Window { - shouldRegenerateGrid: typeof shouldRegenerateGrid; generateGrid: typeof generateGrid; findCell: typeof findClosestCell; @@ -476,4 +604,4 @@ declare global { findAllInQuadtree: typeof findAllInQuadtree; drawHeights: typeof drawHeights; } -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 73581a38..59b4b528 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,13 +1,22 @@ import "./polyfills"; -import { rn, lim, minmax, normalize, lerp } from "./numberUtils"; +import { lerp, lim, minmax, normalize, rn } from "./numberUtils"; + window.rn = rn; window.lim = lim; window.minmax = minmax; window.normalize = normalize; window.lerp = lerp as typeof window.lerp; -import { isVowel, trimVowels, getAdjective, nth, abbreviate, list } from "./languageUtils"; +import { + abbreviate, + getAdjective, + isVowel, + list, + nth, + trimVowels, +} from "./languageUtils"; + window.vowel = isVowel; window.trimVowels = trimVowels; window.getAdjective = getAdjective; @@ -15,7 +24,15 @@ window.nth = nth; window.abbreviate = abbreviate; 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.unique = unique; 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.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.P = P; window.each = each; @@ -38,12 +67,23 @@ window.biased = biased; window.getNumberInRange = getNumberInRange; window.generateSeed = generateSeed; -import { convertTemperature, si, getIntegerFromSI } from "./unitUtils"; -window.convertTemperature = (temp:number, scale: any = (window as any).temperatureScale.value || "°C") => convertTemperature(temp, scale); +import { convertTemperature, getIntegerFromSI, si } from "./unitUtils"; + +window.convertTemperature = ( + temp: number, + scale: any = (window as any).temperatureScale.value || "°C", +) => convertTemperature(temp, scale); window.si = si; window.getInteger = getIntegerFromSI; -import { toHEX, getColors, getRandomColor, getMixedColor, C_12 } from "./colorUtils"; +import { + C_12, + getColors, + getMixedColor, + getRandomColor, + toHEX, +} from "./colorUtils"; + window.toHEX = toHEX; window.getColors = getColors; window.getRandomColor = getRandomColor; @@ -51,21 +91,41 @@ window.getMixedColor = getMixedColor; window.C_12 = C_12; import { getComposedPath, getNextId } from "./nodeUtils"; + window.getComposedPath = getComposedPath; window.getNextId = getNextId; -import { rollups, distanceSquared } from "./functionUtils"; +import { distanceSquared, rollups } from "./functionUtils"; + window.rollups = rollups; window.dist2 = distanceSquared; -import { getIsolines, getPolesOfInaccessibility, connectVertices, findPath, getVertexPath } from "./pathUtils"; +import { + connectVertices, + findPath, + getIsolines, + getPolesOfInaccessibility, + getVertexPath, +} from "./pathUtils"; + window.getIsolines = getIsolines; window.getPolesOfInaccessibility = getPolesOfInaccessibility; window.connectVertices = connectVertices; -window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack); -window.getVertexPath = (cellsArray) => getVertexPath(cellsArray, (window as any).pack); +window.findPath = (start, end, getCost) => + 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.capitalize = capitalize; window.splitInTwo = splitInTwo; @@ -76,6 +136,7 @@ JSON.isValid = isValidJSON; JSON.safeParse = safeParseJSON; import { byId } from "./shorthands"; + window.byId = byId; Node.prototype.on = function (name, fn, options) { this.addEventListener(name, fn, options); @@ -87,27 +148,63 @@ Node.prototype.off = function (name, fn) { }; declare global { - interface JSON { isValid: (str: string) => boolean; safeParse: (str: string) => any; } 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; } } -import { shouldRegenerateGrid, generateGrid, findGridAll, findGridCell, findClosestCell, calculateVoronoi, findAllCellsInRadius, getPackPolygon, getGridPolygon, poissonDiscSampler, isLand, isWater, findAllInQuadtree, drawHeights } from "./graphUtils"; -window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => shouldRegenerateGrid(grid, expectedSeed, (window as any).graphWidth, (window as any).graphHeight); -window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight); -window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid); -window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid); -window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack); -window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack); -window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack); -window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid); +import { + calculateVoronoi, + drawHeights, + findAllCellsInRadius, + findAllInQuadtree, + findClosestCell, + findGridAll, + findGridCell, + 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.poissonDiscSampler = poissonDiscSampler; window.findAllInQuadtree = findAllInQuadtree; @@ -115,8 +212,26 @@ window.drawHeights = drawHeights; window.isLand = (i: number) => isLand(i, (window as any).pack); window.isWater = (i: number) => isWater(i, (window as any).pack); -import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils"; -window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, (window as any).graphWidth, (window as any).graphHeight, secure); +import { + 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.debounce = debounce; window.throttle = throttle; @@ -127,25 +242,37 @@ window.wiki = wiki; window.link = link; window.isCtrlClick = isCtrlClick; window.generateDate = generateDate; -window.getLongitude = (x: number, decimals?: number) => getLongitude(x, (window as any).mapCoordinates, (window as any).graphWidth, decimals); -window.getLatitude = (y: number, decimals?: number) => getLatitude(y, (window as any).mapCoordinates, (window as any).graphHeight, decimals); -window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, (window as any).mapCoordinates, (window as any).graphWidth, (window as any).graphHeight, decimals); +window.getLongitude = (x: number, decimals?: number) => + getLongitude(x, mapCoordinates, graphWidth, 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 -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePrompt); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializePrompt); } else { initializePrompt(); } -import { drawCellsValue, drawPolygons, drawRouteConnections, drawPoint, drawPath } from "./debugUtils"; -window.drawCellsValue = (data:any[]) => drawCellsValue(data, (window as any).pack); -window.drawPolygons = (data: any[]) => drawPolygons(data, (window as any).terrs, (window as any).grid); -window.drawRouteConnections = () => drawRouteConnections((window as any).packedGraph); +import { + drawCellsValue, + drawPath, + 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.drawPath = drawPath; - export { rn, lim, @@ -232,5 +359,5 @@ export { drawPolygons, drawRouteConnections, drawPoint, - drawPath -} \ No newline at end of file + drawPath, +}; diff --git a/src/utils/languageUtils.ts b/src/utils/languageUtils.ts index 0fbd20c8..ea7c8ebb 100644 --- a/src/utils/languageUtils.ts +++ b/src/utils/languageUtils.ts @@ -9,7 +9,7 @@ import { P } from "./probabilityUtils"; export const isVowel = (c: string): boolean => { const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`; return VOWELS.includes(c); -} +}; /** * Remove trailing vowels from a string until it reaches a minimum length. @@ -22,8 +22,7 @@ export const trimVowels = (string: string, minLength: number = 3) => { string = string.slice(0, -1); } return string; -} - +}; /** * Get adjective form of a noun based on predefined rules. @@ -35,131 +34,133 @@ export const getAdjective = (nounToBeAdjective: string) => { { name: "guo", probability: 1, - condition: new RegExp(" Guo$"), - action: (noun: string) => noun.slice(0, -4) + condition: / Guo$/, + action: (noun: string) => noun.slice(0, -4), }, { name: "orszag", probability: 1, - condition: new RegExp("orszag$"), - action: (noun: string) => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6)) + condition: /orszag$/, + action: (noun: string) => + noun.length < 9 ? `${noun}ian` : noun.slice(0, -6), }, { name: "stan", probability: 1, - condition: new RegExp("stan$"), - action: (noun: string) => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4))) + condition: /stan$/, + action: (noun: string) => + noun.length < 9 ? `${noun}i` : trimVowels(noun.slice(0, -4)), }, { name: "land", probability: 1, - condition: new RegExp("land$"), + condition: /land$/, action: (noun: string) => { if (noun.length > 9) return noun.slice(0, -4); const root = trimVowels(noun.slice(0, -4), 0); - if (root.length < 3) return noun + "ic"; - if (root.length < 4) return root + "lish"; - return root + "ish"; - } + if (root.length < 3) return `${noun}ic`; + if (root.length < 4) return `${root}lish`; + return `${root}ish`; + }, }, { name: "que", probability: 1, - condition: new RegExp("que$"), - action: (noun: string) => noun.replace(/que$/, "can") + condition: /que$/, + action: (noun: string) => noun.replace(/que$/, "can"), }, { name: "a", probability: 1, - condition: new RegExp("a$"), - action: (noun: string) => noun + "n" + condition: /a$/, + action: (noun: string) => `${noun}n`, }, { name: "o", probability: 1, - condition: new RegExp("o$"), - action: (noun: string) => noun.replace(/o$/, "an") + condition: /o$/, + action: (noun: string) => noun.replace(/o$/, "an"), }, { name: "u", probability: 1, - condition: new RegExp("u$"), - action: (noun: string) => noun + "an" + condition: /u$/, + action: (noun: string) => `${noun}an`, }, { name: "i", probability: 1, - condition: new RegExp("i$"), - action: (noun: string) => noun + "an" + condition: /i$/, + action: (noun: string) => `${noun}an`, }, { name: "e", probability: 1, - condition: new RegExp("e$"), - action: (noun: string) => noun + "an" + condition: /e$/, + action: (noun: string) => `${noun}an`, }, { name: "ay", probability: 1, - condition: new RegExp("ay$"), - action: (noun: string) => noun + "an" + condition: /ay$/, + action: (noun: string) => `${noun}an`, }, { name: "os", probability: 1, - condition: new RegExp("os$"), + condition: /os$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length < 4) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "es", probability: 1, - condition: new RegExp("es$"), + condition: /es$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length > 7) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "l", probability: 0.8, - condition: new RegExp("l$"), - action: (noun: string) => noun + "ese" + condition: /l$/, + action: (noun: string) => `${noun}ese`, }, { name: "n", probability: 0.8, - condition: new RegExp("n$"), - action: (noun: string) => noun + "ese" + condition: /n$/, + action: (noun: string) => `${noun}ese`, }, { name: "ad", probability: 0.8, - condition: new RegExp("ad$"), - action: (noun: string) => noun + "ian" + condition: /ad$/, + action: (noun: string) => `${noun}ian`, }, { name: "an", probability: 0.8, - condition: new RegExp("an$"), - action: (noun: string) => noun + "ian" + condition: /an$/, + action: (noun: string) => `${noun}ian`, }, { name: "ish", probability: 0.25, - condition: new RegExp("^[a-zA-Z]{6}$"), - action: (noun: string) => trimVowels(noun.slice(0, -1)) + "ish" + condition: /^[a-zA-Z]{6}$/, + action: (noun: string) => `${trimVowels(noun.slice(0, -1))}ish`, }, { name: "an", probability: 0.5, - condition: new RegExp("^[a-zA-Z]{0,7}$"), - action: (noun: string) => trimVowels(noun) + "an" - } + condition: /^[a-zA-Z]{0,7}$/, + action: (noun: string) => `${trimVowels(noun)}an`, + }, ]; for (const rule of adjectivizationRules) { 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 -} +}; /** * Get the ordinal suffix for a given number. * @param n - The number. * @returns The number with its ordinal suffix. */ -export const nth = (n: number) => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th"); +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. @@ -187,12 +189,13 @@ export const abbreviate = (name: string, restricted: string[] = []) => { const words = parsed.split(" "); 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++) { code = letters[0] + letters[i].toUpperCase(); } return code; -} +}; /** * 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[]) => { 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); -} +}; declare global { interface Window { @@ -214,4 +220,4 @@ declare global { abbreviate: typeof abbreviate; list: typeof list; } -} \ No newline at end of file +} diff --git a/src/utils/nodeUtils.ts b/src/utils/nodeUtils.ts index 6213840f..f5c66705 100644 --- a/src/utils/nodeUtils.ts +++ b/src/utils/nodeUtils.ts @@ -3,14 +3,14 @@ * @param {Node | Window} node - The starting node or window * @returns {Array} - The composed path as an array */ -export const getComposedPath = function(node: any): Array { - let parent; +export const getComposedPath = (node: any): Array => { + let parent: Node | Window | undefined; if (node.parentNode) parent = node.parentNode; else if (node.host) parent = node.host; else if (node.defaultView) parent = node.defaultView; if (parent !== undefined) return [node].concat(getComposedPath(parent)); return [node]; -} +}; /** * Generate a unique ID for a given core string @@ -18,14 +18,14 @@ export const getComposedPath = function(node: any): Array { * @param {number} [i=1] - The starting index * @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++; return core + i; -} +}; declare global { interface Window { getComposedPath: typeof getComposedPath; getNextId: typeof getNextId; } -} \ No newline at end of file +} diff --git a/src/utils/numberUtils.ts b/src/utils/numberUtils.ts index a2ab6220..d7516624 100644 --- a/src/utils/numberUtils.ts +++ b/src/utils/numberUtils.ts @@ -5,9 +5,9 @@ * @returns The rounded number. */ export const rn = (v: number, d: number = 0) => { - const m = Math.pow(10, d); + const m = 10 ** d; return Math.round(v * m) / m; -} +}; /** * 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) => { return Math.min(Math.max(value, min), max); -} +}; /** * 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) => { return minmax(v, 0, 100); -} +}; /** * 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) => { return minmax((val - min) / (max - min), 0, 1); -} +}; /** * 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) => { return a + (b - a) * t; -} +}; declare global { interface Window { @@ -59,4 +59,4 @@ declare global { normalize: typeof normalize; lerp: typeof lerp; } -} \ No newline at end of file +} diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index b37f17fb..36baec86 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -8,10 +8,10 @@ import { rn } from "./numberUtils"; * @returns {string} SVG path data for the filled shape. */ 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(); return `M${firstPoint} L${points.join(" ")} Z`; -} +}; /** * 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. * @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 lastOperation = ""; - const path = vertexChain.map(vertexId => { + const path = vertexChain.map((vertexId) => { if (discontinue(vertexId)) { discontinued = true; return ""; @@ -33,12 +37,13 @@ const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (verte discontinued = false; lastOperation = operation; - const command = operation === "L" && operation === lastOperation ? "" : operation; + const command = + operation === "L" && operation === lastOperation ? "" : operation; return ` ${command}${vertices.p[vertexId]}`; }); return path.join("").trim(); -} +}; /** * Restores the path from exit to start using the 'from' mapping. @@ -62,7 +67,7 @@ const restorePath = (exit: number, start: number, from: number[]) => { pathCells.push(current); return pathCells.reverse(); -} +}; /** * 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. * @returns {object} An object containing isolines for each type based on the specified options. */ -export const getIsolines = (graph: any, getType: (cellId: number) => any, options: {polygons?: boolean, fill?: boolean, halo?: boolean, waterGap?: boolean} = {polygons: false, fill: false, halo: false, waterGap: false}): any => { - const {cells, vertices} = graph; +export const getIsolines = ( + graph: any, + getType: (cellId: number) => any, + options: { + polygons?: boolean; + fill?: boolean; + halo?: boolean; + waterGap?: boolean; + } = { polygons: false, fill: false, halo: false, waterGap: false }, +): any => { + const { cells, vertices } = graph; const isolines: any = {}; const checkedCells = new Uint8Array(cells.i.length); - const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; const isChecked = (cellId: number) => checkedCells[cellId] === 1; 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 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)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; addIsolineTo(type, vertices, vertexChain, isolines, options); @@ -109,12 +135,20 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option 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 (options.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) { @@ -124,18 +158,27 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option if (options.waterGap) { if (!isolines[type].waterGap) isolines[type].waterGap = ""; - const isLandVertex = (vertexId: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); - isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex); + const isLandVertex = (vertexId: number) => + vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); + isolines[type].waterGap += getBorderPath( + vertices, + vertexChain, + isLandVertex, + ); } if (options.halo) { if (!isolines[type].halo) isolines[type].halo = ""; - const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]); - isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex); + const isBorderVertex = (vertexId: number) => + vertices.c[vertexId].some((i: number) => cells.b[i]); + isolines[type].halo += getBorderPath( + vertices, + vertexChain, + isBorderVertex, + ); } } -} - +}; /** * Generates SVG path data for the border of a shape defined by a chain of vertices. @@ -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. */ 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 ofDifferentType = (cellId: number) => !cellsObj[cellId]; 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; let path = ""; @@ -166,17 +213,26 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { if (feature.shoreline.every(ofSameType)) continue; // inner lake } - const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; path += getFillPath(vertices, vertexChain); } return path; -} +}; /** * Finds the poles of inaccessibility for each type of cell in the graph. @@ -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. * @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y]. */ -export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => { - const isolines = getIsolines(graph, getType, {polygons: true}); +export const getPolesOfInaccessibility = ( + graph: any, + getType: (cellId: number) => any, +) => { + const isolines = getIsolines(graph, getType, { polygons: true }); const poles = Object.entries(isolines).map(([id, isoline]) => { - const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length); + const multiPolygon = (isoline as any).polygons.sort( + (a: any, b: any) => b.length - a.length, + ); const [x, y] = polylabel(multiPolygon, 20); return [id, [rn(x), rn(y)]]; }); return Object.fromEntries(poles); -} +}; /** * Connects vertices to form a closed path based on cell type. @@ -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. * @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 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; 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; } if (next === current) { - window.ERROR && console.error("ConnectVertices: next vertex is not found"); + window.ERROR && + console.error("ConnectVertices: next vertex is not found"); break; } if (i === MAX_ITERATIONS) { - window.ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS); + window.ERROR && + console.error( + "ConnectVertices: max iterations reached", + MAX_ITERATIONS, + ); break; } } if (closeRing) chain.push(startingVertex); return chain; -} +}; /** * Finds the shortest path between two cells using a cost-based pathfinding algorithm. @@ -254,7 +333,12 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec * @param {object} packedGraph - The packed graph object containing cells and their connections. * @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same. */ -export const findPath = (start: number, isExit: (id: number) => boolean, getCost: (current: number, next: number) => number, packedGraph: any = {}): number[] | null => { +export const findPath = ( + start: number, + isExit: (id: number) => boolean, + getCost: (current: number, next: number) => number, + packedGraph: any = {}, +): number[] | null => { if (isExit(start)) return null; const from = []; @@ -284,7 +368,7 @@ export const findPath = (start: number, isExit: (id: number) => boolean, getCost } return null; -} +}; declare global { interface Window { @@ -297,4 +381,4 @@ declare global { findPath: typeof findPath; getVertexPath: typeof getVertexPath; } -} \ No newline at end of file +} diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts index 18f5f1bd..594e7a2f 100644 --- a/src/utils/polyfills.ts +++ b/src/utils/polyfills.ts @@ -1,7 +1,11 @@ // replaceAll if (String.prototype.replaceAll === undefined) { - String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string { - if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any); + String.prototype.replaceAll = function ( + str: string | RegExp, + newStr: string | ((substring: string, ...args: any[]) => string), + ): string { + if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") + return this.replace(str as RegExp, newStr as any); return this.replace(new RegExp(str, "g"), newStr as any); }; } @@ -9,7 +13,13 @@ if (String.prototype.replaceAll === undefined) { // flat if (Array.prototype.flat === undefined) { Array.prototype.flat = function (this: T[], depth?: number): any[] { - return (this as Array).reduce((acc: any[], val: unknown) => (Array.isArray(val) ? acc.concat((val as any).flat(depth)) : acc.concat(val)), []); + return (this as Array).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 if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { - (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* (this: ReadableStream): AsyncGenerator { + (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* ( + this: ReadableStream, + ): AsyncGenerator { const reader = this.getReader(); try { while (true) { - const {done, value} = await reader.read(); + const { done, value } = await reader.read(); if (done) return; yield value; } @@ -40,7 +52,10 @@ if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { declare global { 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 { diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts index a526c981..ba9806b9 100644 --- a/src/utils/probabilityUtils.ts +++ b/src/utils/probabilityUtils.ts @@ -1,5 +1,5 @@ -import { minmax, rn } from "./numberUtils"; import { randomNormal } from "d3"; +import { minmax, rn } from "./numberUtils"; /** * Creates a random number between min and max (inclusive). @@ -14,7 +14,7 @@ export const rand = (min: number, max?: number): number => { min = 0; } return Math.floor(Math.random() * (max - min + 1)) + min; -} +}; /** * 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 <= 0) return false; return Math.random() < probability; -} +}; /** * Returns true every n times. @@ -34,7 +34,7 @@ export const P = (probability: number): boolean => { */ export const each = (n: number) => { return (i: number) => i % n === 0; -} +}; /** * Random Gaussian number generator @@ -46,10 +46,23 @@ export const each = (n: number) => { * @param {number} round - round value to n decimals * @return {number} random number */ -export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { +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) - 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. @@ -58,7 +71,7 @@ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round */ export const Pint = (float: number): number => { return ~~float + +P(float % 1); -} +}; /** * Returns a random element from an array. @@ -67,18 +80,18 @@ export const Pint = (float: number): number => { */ export const ra = (array: any[]): any => { return array[Math.floor(Math.random() * array.length)]; -} +}; /** * Returns a random key from an object where values are weights. * @param {Object} object - object with keys and their weights * @return {string} a random key based on weights - * + * * @example * const obj = { a: 1, b: 3, c: 6 }; * const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance */ -export const rw = (object: {[key: string]: number}): string => { +export const rw = (object: { [key: string]: number }): string => { const array = []; for (const key in object) { 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)]; -} +}; /** * 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 */ 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; /** @@ -110,28 +123,28 @@ export const getNumberInRange = (r: string): number => { ERROR && console.error("Range value should be a string", r); 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; - if (isNaN(+r[0])) r = r.slice(1); + if (Number.isNaN(+r[0])) r = r.slice(1); const range = r.includes("-") ? r.split("-") : null; if (!range) { ERROR && console.error("Cannot parse the number. Check the format", r); return 0; } const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1])); - if (isNaN(count) || count < 0) { + if (Number.isNaN(count) || count < 0) { ERROR && console.error("Cannot parse number. Check the format", r); return 0; } return count; -} +}; /** * Generate a random seed string * @return {string} random seed */ export const generateSeed = (): string => { return String(Math.floor(Math.random() * 1e9)); -} +}; declare global { interface Window { @@ -146,4 +159,4 @@ declare global { getNumberInRange: typeof getNumberInRange; generateSeed: typeof generateSeed; } -} \ No newline at end of file +} diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts index 10da484f..86af5c39 100644 --- a/src/utils/stringUtils.test.ts +++ b/src/utils/stringUtils.test.ts @@ -1,8 +1,8 @@ -import { expect, describe, it } from 'vitest' -import { round } from './stringUtils' +import { describe, expect, it } from "vitest"; +import { round } from "./stringUtils"; -describe('round', () => { - it('should be able to handle undefined input', () => { +describe("round", () => { + it("should be able to handle undefined input", () => { expect(round(undefined)).toBe(""); }); -}) \ No newline at end of file +}); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index dc00a23a..01d3f38d 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -7,10 +7,10 @@ import { rn } from "./numberUtils"; * @returns {string} - The string with rounded numbers */ export const round = (inputString: string = "", decimals: number = 1) => { - return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { + return inputString.replace(/[\d.-][\d.e-]*/g, (n: string) => { return rn(parseFloat(n), decimals).toString(); }); -} +}; /** * Capitalize the first letter of a string @@ -19,7 +19,7 @@ export const round = (inputString: string = "", decimals: number = 1) => { */ export const capitalize = (inputString: string) => { return inputString.charAt(0).toUpperCase() + inputString.slice(1); -} +}; /** * Split a string into two parts, trying to balance their lengths @@ -46,13 +46,13 @@ export const splitInTwo = (inputString: string): string[] => { if (!last) return [first, middle]; if (first.length < last.length) return [first + middle, last]; return [first, middle + last]; -} +}; /** * Parse an SVG transform string into an array of numbers * @param {string} string - The SVG transform string * @returns {[number, number, number, number, number, number]} - The parsed transform as an array - * + * * @example * parseTransform("matrix(1, 0, 0, 1, 100, 200)") // returns [1, 0, 0, 1, 100, 200] * parseTransform("translate(50, 75)") // returns [50, 75, 0, 0, 0, 1] @@ -65,7 +65,7 @@ export const parseTransform = (string: string) => { .replace(/[ ]/g, ",") .split(","); return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1]; -} +}; /** * Check if a string is valid JSON @@ -76,7 +76,7 @@ export const isValidJSON = (str: string): boolean => { try { JSON.parse(str); return true; - } catch (e) { + } catch (_e) { return false; } }; @@ -89,7 +89,7 @@ export const isValidJSON = (str: string): boolean => { export const safeParseJSON = (str: string) => { try { return JSON.parse(str); - } catch (e) { + } catch (_e) { return null; } }; @@ -109,10 +109,10 @@ export const sanitizeId = (inputString: string) => { .replace(/\s+/g, "-"); // replace spaces with hyphens // remove leading numbers - if (sanitized.match(/^\d/)) sanitized = "_" + sanitized; + if (sanitized.match(/^\d/)) sanitized = `_${sanitized}`; return sanitized; -} +}; declare global { interface Window { @@ -122,4 +122,4 @@ declare global { parseTransform: typeof parseTransform; sanitizeId: typeof sanitizeId; } -} \ No newline at end of file +} diff --git a/src/utils/unitUtils.ts b/src/utils/unitUtils.ts index 072c0b38..142e139c 100644 --- a/src/utils/unitUtils.ts +++ b/src/utils/unitUtils.ts @@ -7,19 +7,23 @@ type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" | * @param {string} targetScale - Target temperature scale * @returns {string} - Converted temperature with unit */ -export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => { - const temperatureConversionMap: {[key: string]: (temp: number) => string} = { - "°C": (temp: number) => rn(temp) + "°C", - "°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F", - K: (temp: number) => rn(temp + 273.15) + "K", - "°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R", - "°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De", - "°N": (temp: number) => rn((temp * 33) / 100) + "°N", - "°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré", - "°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø" - }; +export const convertTemperature = ( + temperatureInCelsius: number, + targetScale: TemperatureScale = "°C", +) => { + const temperatureConversionMap: { [key: string]: (temp: number) => string } = + { + "°C": (temp: number) => `${rn(temp)}°C`, + "°F": (temp: number) => `${rn((temp * 9) / 5 + 32)}°F`, + K: (temp: number) => `${rn(temp + 273.15)}K`, + "°R": (temp: number) => `${rn(((temp + 273.15) * 9) / 5)}°R`, + "°De": (temp: number) => `${rn(((100 - temp) * 3) / 2)}°De`, + "°N": (temp: number) => `${rn((temp * 33) / 100)}°N`, + "°Ré": (temp: number) => `${rn((temp * 4) / 5)}°Ré`, + "°Rø": (temp: number) => `${rn((temp * 21) / 40 + 7.5)}°Rø`, + }; return temperatureConversionMap[targetScale](temperatureInCelsius); -} +}; /** * Convert number to short string with SI postfix @@ -27,13 +31,13 @@ export const convertTemperature = (temperatureInCelsius: number, targetScale: Te * @returns {string} - The converted string */ export const si = (n: number): string => { - if (n >= 1e9) return rn(n / 1e9, 1) + "B"; - if (n >= 1e8) return rn(n / 1e6) + "M"; - if (n >= 1e6) return rn(n / 1e6, 1) + "M"; - if (n >= 1e4) return rn(n / 1e3) + "K"; - if (n >= 1e3) return rn(n / 1e3, 1) + "K"; + if (n >= 1e9) return `${rn(n / 1e9, 1)}B`; + if (n >= 1e8) return `${rn(n / 1e6)}M`; + if (n >= 1e6) return `${rn(n / 1e6, 1)}M`; + if (n >= 1e4) return `${rn(n / 1e3)}K`; + if (n >= 1e3) return `${rn(n / 1e3, 1)}K`; return rn(n).toString(); -} +}; /** * Convert string with SI postfix to integer @@ -42,11 +46,11 @@ export const si = (n: number): string => { */ export const getIntegerFromSI = (value: string): number => { const metric = value.slice(-1); - if (metric === "K") return parseInt(value.slice(0, -1)) * 1e3; - if (metric === "M") return parseInt(value.slice(0, -1)) * 1e6; - if (metric === "B") return parseInt(value.slice(0, -1)) * 1e9; - return parseInt(value); -} + if (metric === "K") return parseInt(value.slice(0, -1), 10) * 1e3; + if (metric === "M") return parseInt(value.slice(0, -1), 10) * 1e6; + if (metric === "B") return parseInt(value.slice(0, -1), 10) * 1e9; + return parseInt(value, 10); +}; declare global { interface Window {