Merge branch 'master' into refactor/migrate-first-modules

This commit is contained in:
Marc Emmanuel 2026-01-25 18:54:06 +01:00
commit 613e826133
55 changed files with 1473 additions and 214 deletions

25
.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Playwright 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: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

3
.gitignore vendored
View file

@ -1,5 +1,8 @@
.vscode
.idea
/node_modules
*/node_modules
/dist
/coverage
/playwright-report
/test-results

592
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "fantasy-map-generator",
"version": "1.109.5",
"version": "1.110.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fantasy-map-generator",
"version": "1.109.5",
"version": "1.110.0",
"license": "MIT",
"dependencies": {
"alea": "^1.0.1",
@ -15,11 +15,17 @@
"polylabel": "^2.0.1"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/d3": "^7.4.3",
"@types/delaunator": "^5.0.3",
"@types/node": "^25.0.10",
"@types/polylabel": "^1.1.3",
"@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.57.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=24.0.0"
@ -467,6 +473,36 @@
"node": ">=18"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
@ -817,6 +853,24 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -1101,6 +1155,13 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/delaunator": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/delaunator/-/delaunator-5.0.3.tgz",
@ -1122,6 +1183,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/polylabel": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
@ -1129,12 +1201,191 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/browser": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz",
"integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/mocker": "4.0.18",
"@vitest/utils": "4.0.18",
"magic-string": "^0.30.21",
"pixelmatch": "7.1.0",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.0.3",
"ws": "^8.18.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.0.18"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz",
"integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/browser": "4.0.18",
"@vitest/mocker": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.0.18"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/alea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz",
"integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==",
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@ -1555,6 +1806,13 @@
"robust-predicates": "^3.0.2"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@ -1597,6 +1855,26 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1651,6 +1929,26 @@
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1670,6 +1968,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1690,6 +2006,77 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"dependencies": {
"pngjs": "^7.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/polylabel": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz",
@ -1791,6 +2178,28 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1801,6 +2210,37 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -1824,6 +2264,26 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -1838,12 +2298,20 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -1912,6 +2380,124 @@
"optional": true
}
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -16,14 +16,23 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:browser": "vitest --config=vitest.browser.config.ts",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/d3": "^7.4.3",
"@types/delaunator": "^5.0.3",
"@types/node": "^25.0.10",
"@types/polylabel": "^1.1.3",
"@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.57.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"dependencies": {
"alea": "^1.0.1",

34
playwright.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test'
const isCI = !!process.env.CI
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: 'html',
// Use OS-independent snapshot names (HTML content is the same across platforms)
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}',
use: {
baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173',
trace: 'on-first-retry',
// Fixed viewport to ensure consistent map rendering
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
// In CI: build and preview for production-like testing
// In dev: use vite dev server (faster, no rebuild needed)
command: isCI ? 'npm run build && npm run preview' : 'npm run dev',
url: isCI ? 'http://localhost:4173' : 'http://localhost:5173',
reuseExistingServer: !isCI,
timeout: 120000,
},
})

View file

@ -632,6 +632,8 @@ async function generate(options) {
Biomes.define();
Features.defineGroups();
Ice.generate();
rankCells();
Cultures.generate();
Cultures.expand();
@ -1227,8 +1229,12 @@ function showStatistics() {
Cultures: ${pack.cultures.length - 1}`;
mapId = Date.now(); // unique map id is it's creation date number
window.mapId = mapId; // expose for test automation
mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId});
INFO && console.info(stats);
// Dispatch event for test automation and external integrations
window.dispatchEvent(new CustomEvent('map:generated', { detail: { seed, mapId } }));
}
const regenerateMap = debounce(async function (options) {

View file

@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) {
const source = findCell(s.x, s.y);
const mouth = findCell(e.x, e.y);
const name = Rivers.getName(mouth);
const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River";
pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type});
const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River";
pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type });
});
}
@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) {
const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era";
const eraShort = era[0] + "E";
const military = Military.getDefaultOptions();
options = {winds, year, era, eraShort, military};
options = { winds, year, era, eraShort, military };
// v1.3 added campaings data for all states
States.generateCampaigns();
@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) {
if (isOlderThan("1.65.0")) {
// v1.65 changed rivers data
d3.select("#rivers").attr("style", null); // remove style to unhide layer
const {cells, rivers} = pack;
const { cells, rivers } = pack;
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
for (const river of rivers) {
@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) {
for (let i = 0; i <= segments; i++) {
const shift = increment * i;
const {x: x1, y: y1} = node.getPointAtLength(length + shift);
const {x: x2, y: y2} = node.getPointAtLength(length - shift);
const { x: x1, y: y1 } = node.getPointAtLength(length + shift);
const { x: x2, y: y2 } = node.getPointAtLength(length - shift);
const x = rn((x1 + x2) / 2, 1);
const y = rn((y1 + y2) / 2, 1);
@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) {
const fill = circle && circle.getAttribute("fill");
const stroke = circle && circle.getAttribute("stroke");
const marker = {i, icon, type, x, y, size, cell};
const marker = { i, icon, type, x, y, size, cell };
if (size && size !== 30) marker.size = size;
if (!isNaN(px) && px !== 12) marker.px = px;
if (!isNaN(dx) && dx !== 50) marker.dx = dx;
@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) {
if (isOlderThan("1.88.0")) {
// v1.87 may have incorrect shield for some reason
pack.states.forEach(({coa}) => {
pack.states.forEach(({ coa }) => {
if (coa?.shield === "state") delete coa.shield;
});
}
@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) {
if (isOlderThan("1.91.0")) {
// from 1.91.00 custom coa is moved to coa object
pack.states.forEach(state => {
if (state.coa === "custom") state.coa = {custom: true};
if (state.coa === "custom") state.coa = { custom: true };
});
pack.provinces.forEach(province => {
if (province.coa === "custom") province.coa = {custom: true};
if (province.coa === "custom") province.coa = { custom: true };
});
pack.burgs.forEach(burg => {
if (burg.coa === "custom") burg.coa = {custom: true};
if (burg.coa === "custom") burg.coa = { custom: true };
});
// from 1.91.00 emblems don't have transform attribute
@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) {
const skip = terrs.attr("skip");
const relax = terrs.attr("relax");
const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"};
const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" };
const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed";
terrs
@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) {
const secondCellId = points[1][2];
const feature = pack.cells.f[secondCellId];
pack.routes.push({i: pack.routes.length, group, feature, points});
pack.routes.push({ i: pack.routes.length, group, feature, points });
}
}
routes.selectAll("path").remove();
@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) {
const type = this.dataset.type;
const color = this.getAttribute("fill");
const cells = this.dataset.cells.split(",").map(Number);
pack.zones.push({i, name, type, cells, color});
pack.zones.push({ i, name, type, cells, color });
});
zones.style("display", null).selectAll("*").remove();
if (layerIsOn("toggleZones")) drawZones();
@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) {
if (isOlderThan("1.109.0")) {
// v1.109.0 added customizable burg groups and icons
options.burgs = {groups: []};
options.burgs = { groups: [] };
burgIcons.selectAll("circle, use").each(function () {
const group = this.parentNode.id;
@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) {
burgIcons.selectAll("g").each(function (_el, index) {
const name = this.id;
const isDefault = name === "towns";
options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"});
options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" });
if (!this.dataset.icon) this.dataset.icon = "#icon-circle";
const size = Number(this.getAttribute("size") || 2) * 2;
@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) {
delete options.showMFCGMap;
delete options.villageMaxPopulation;
}
if (isOlderThan("1.111.0")) {
// v1.111.0 moved ice data from SVG to data model
// Migrate old ice SVG elements to new pack.ice structure
if (!pack.ice.length) {
pack.ice = [];
let iceId = 0;
const iceLayer = document.getElementById("ice");
if (iceLayer) {
// Migrate glaciers (type="iceShield")
iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => {
// Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...]
const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]);
const transform = polygon.getAttribute("transform");
const iceElement = {
i: iceId++,
points,
type: "glacier"
};
if (transform) {
iceElement.offset = parseTransform(transform);
}
pack.ice.push(iceElement);
});
// Migrate icebergs
iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => {
const cellId = +polygon.getAttribute("cell");
const size = +polygon.getAttribute("size");
// points string must exist, cell attribute must be present, and size must be non-zero
if (polygon.getAttribute("cell") === null || !size) return;
// Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...]
const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]);
const transform = polygon.getAttribute("transform");
const iceElement = {
i: iceId++,
points,
type: "iceberg",
cellId,
size
};
if (transform) {
iceElement.offset = parseTransform(transform);
}
pack.ice.push(iceElement);
});
// Clear old SVG elements
iceLayer.querySelectorAll("*").forEach(el => el.remove());
} else {
// If ice layer element doesn't exist, create it
ice = viewbox.insert("g", "#coastline").attr("id", "ice");
ice
.attr("opacity", null)
.attr("fill", "#e8f0f6")
.attr("stroke", "#e8f0f6")
.attr("stroke-width", 1)
.attr("filter", "url(#dropShadow05)");
}
// Re-render ice from migrated data
if (layerIsOn("toggleIce")) drawIce();
}
}
}

170
public/modules/ice.js Normal file
View file

@ -0,0 +1,170 @@
"use strict";
// Ice layer data model - separates ice data from SVG rendering
window.Ice = (function () {
// Find next available id for new ice element idealy filling gaps
function getNextId() {
if (pack.ice.length === 0) return 0;
// find gaps in existing ids
const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b);
for (let id = 0; id < existingIds[existingIds.length - 1]; id++) {
if (!existingIds.includes(id)) return id;
}
return existingIds[existingIds.length - 1] + 1;
}
// Generate glaciers and icebergs based on temperature and height
function generate() {
clear();
const { cells, features } = grid;
const { temp, h } = cells;
Math.random = aleaPRNG(seed);
const ICEBERG_MAX_TEMP = 0;
const GLACIER_MAX_TEMP = -8;
const minMaxTemp = d3.min(temp);
// Generate glaciers on cold land
{
const type = "iceShield";
const getType = cellId =>
h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null;
const isolines = getIsolines(grid, getType, { polygons: true });
if (isolines[type]?.polygons) {
isolines[type].polygons.forEach(points => {
const clipped = clipPoly(points);
pack.ice.push({
i: getNextId(),
points: clipped,
type: "glacier"
});
});
}
}
// Generate icebergs on cold water
for (const cellId of grid.cells.i) {
const t = temp[cellId];
if (h[cellId] >= 20) continue; // no icebergs on land
if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs
if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes
if (P(0.8)) continue; // skip most of eligible cells
const randomFactor = 0.8 + rand() * 0.4; // random size factor
let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full
if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs
const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1);
const [cx, cy] = grid.points[cellId];
const points = getGridPolygon(cellId).map(([x, y]) => [
rn(lerp(cx, x, size), 2),
rn(lerp(cy, y, size), 2)
]);
pack.ice.push({
i: getNextId(),
points,
type: "iceberg",
cellId,
size
});
}
}
function addIceberg(cellId, size) {
const [cx, cy] = grid.points[cellId];
const points = getGridPolygon(cellId).map(([x, y]) => [
rn(lerp(cx, x, size), 2),
rn(lerp(cy, y, size), 2)
]);
const id = getNextId();
pack.ice.push({
i: id,
points,
type: "iceberg",
cellId,
size
});
redrawIceberg(id);
}
function removeIce(id) {
const index = pack.ice.findIndex(element => element.i === id);
if (index !== -1) {
const type = pack.ice.find(element => element.i === id).type;
pack.ice.splice(index, 1);
if (type === "glacier") {
redrawGlacier(id);
} else {
redrawIceberg(id);
}
}
}
function updateIceberg(id, points, size) {
const iceberg = pack.ice.find(element => element.i === id);
if (iceberg) {
iceberg.points = points;
iceberg.size = size;
}
}
function randomizeIcebergShape(id) {
const iceberg = pack.ice.find(element => element.i === id);
if (!iceberg) return;
const cellId = iceberg.cellId;
const size = iceberg.size;
const [cx, cy] = grid.points[cellId];
// Get a different random cell for the polygon template
const i = ra(grid.cells.i);
const cn = grid.points[i];
const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]);
const points = poly.map(p => [
rn(cx + p[0] * size, 2),
rn(cy + p[1] * size, 2)
]);
iceberg.points = points;
}
function changeIcebergSize(id, newSize) {
const iceberg = pack.ice.find(element => element.i === id);
if (!iceberg) return;
const cellId = iceberg.cellId;
const [cx, cy] = grid.points[cellId];
const oldSize = iceberg.size;
const flat = iceberg.points.flat();
const pairs = [];
while (flat.length) pairs.push(flat.splice(0, 2));
const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]);
const points = poly.map(p => [
rn(cx + p[0] * newSize, 2),
rn(cy + p[1] * newSize, 2)
]);
iceberg.points = points;
iceberg.size = newSize;
}
// Clear all ice
function clear() {
pack.ice = [];
}
return {
generate,
addIceberg,
removeIce,
updateIceberg,
randomizeIcebergShape,
changeIcebergSize,
clear
};
})();

View file

@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) {
pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length);
// data[28] had deprecated cells.crossroad
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
pack.ice = data[39] ? JSON.parse(data[39]) : [];
if (data[31]) {
const namesDL = data[31].split("/");
@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) {
if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes");
if (hasChildren(temperature)) turnOn("toggleTemperature");
if (hasChild(population, "line")) turnOn("togglePopulation");
if (hasChildren(ice)) turnOn("toggleIce");
if (isVisible(ice)) turnOn("toggleIce");
if (hasChild(prec, "circle")) turnOn("togglePrecipitation");
if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems");
if (isVisible(labels)) turnOn("toggleLabels");

View file

@ -32,12 +32,13 @@ async function saveMap(method) {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
position: { my: "center", at: "center", of: "svg" }
});
}
}
function prepareMapData() {
const date = new Date();
const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator";
@ -89,8 +90,8 @@ function prepareMapData() {
const serializedSVG = new XMLSerializer().serializeToString(cloneEl);
const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid;
const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired});
const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid;
const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired });
const packFeatures = JSON.stringify(pack.features);
const cultures = JSON.stringify(pack.cultures);
const states = JSON.stringify(pack.states);
@ -102,6 +103,7 @@ function prepareMapData() {
const cellRoutes = JSON.stringify(pack.cells.routes);
const routes = JSON.stringify(pack.routes);
const zones = JSON.stringify(pack.zones);
const ice = JSON.stringify(pack.ice);
// store name array only if not the same as default
const defaultNB = Names.getNameBases();
@ -155,21 +157,22 @@ function prepareMapData() {
markers,
cellRoutes,
routes,
zones
zones,
ice
].join("\r\n");
return mapData;
}
// save map file to indexedDB
async function saveToStorage(mapData, showTip = false) {
const blob = new Blob([mapData], {type: "text/plain"});
const blob = new Blob([mapData], { type: "text/plain" });
await ldb.set("lastMap", blob);
showTip && tip("Map is saved to the browser storage", false, "success");
}
// download map file
function saveToMachine(mapData, filename) {
const blob = new Blob([mapData], {type: "text/plain"});
const blob = new Blob([mapData], { type: "text/plain" });
const URL = window.URL.createObjectURL(blob);
const link = document.createElement("a");

View file

@ -0,0 +1,70 @@
"use strict";
// Ice layer renderer - renders ice from data model to SVG
function drawIce() {
TIME && console.time("drawIce");
// Clear existing ice SVG
ice.selectAll("*").remove();
let html = "";
// Draw all ice elements
pack.ice.forEach(iceElement => {
if (iceElement.type === "glacier") {
html += getGlacierHtml(iceElement);
} else if (iceElement.type === "iceberg") {
html += getIcebergHtml(iceElement);
}
});
ice.html(html);
TIME && console.timeEnd("drawIce");
}
function redrawIceberg(id) {
TIME && console.time("redrawIceberg");
const iceberg = pack.ice.find(element => element.i === id);
let el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`);
if (!iceberg && !el.empty()) {
el.remove();
} else {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getIcebergHtml(iceberg);
ice.node().insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`);
}
el.attr("points", iceberg.points);
el.attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null);
}
TIME && console.timeEnd("redrawIceberg");
}
function redrawGlacier(id) {
TIME && console.time("redrawGlacier");
const glacier = pack.ice.find(element => element.i === id);
let el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`);
if (!glacier && !el.empty()) {
el.remove();
} else {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getGlacierHtml(glacier);
ice.node().insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`);
}
el.attr("points", glacier.points);
el.attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null);
}
TIME && console.timeEnd("redrawGlacier");
}
function getGlacierHtml(glacier) {
return `<polygon points="${glacier.points}" type="glacier" data-id="${glacier.i}" ${glacier.offset ? `transform="translate(${glacier.offset[0]},${glacier.offset[1]})"` : ""}/>`;
}
function getIcebergHtml(iceberg) {
return `<polygon points="${iceberg.points}" data-id="${iceberg.i}" ${iceberg.offset ? `transform="translate(${iceberg.offset[0]},${iceberg.offset[1]})"` : ""}/>`;
}

View file

@ -26,7 +26,7 @@ function clicked() {
else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel();
else if (grand.id === "burgLabels") editBurg();
else if (grand.id === "burgIcons") editBurg();
else if (parent.id === "ice") editIce();
else if (parent.id === "ice") editIce(el);
else if (parent.id === "terrain") editReliefIcon();
else if (grand.id === "markers" || great.id === "markers") editMarker();
else if (grand.id === "coastline") editCoastline();

View file

@ -259,6 +259,8 @@ function editHeightmap(options) {
Rivers.specify();
Lakes.defineNames();
Ice.generate();
Military.generate();
Markers.generate();
Zones.generate();
@ -465,6 +467,10 @@ function editHeightmap(options) {
.attr("id", d => base + d);
});
// recalculate ice
Ice.generate();
ice.selectAll("*").remove();
TIME && console.timeEnd("restoreRiskedData");
INFO && console.groupEnd("Edit Heightmap");
}
@ -669,7 +675,7 @@ function editHeightmap(options) {
if (power === 0) return tip("Power should not be zero", false, "error");
const heights = grid.cells.h;
const operation = power > 0 ? HeightmapGenerator.addRange : HeightmapGenerator.addTrough;
const operation = power > 0 ? HeightmapGenerator.addRange.bind(HeightmapGenerator) : HeightmapGenerator.addTrough.bind(HeightmapGenerator);
HeightmapGenerator.setGraph(grid);
operation("1", String(Math.abs(power)), null, null, fromCell, toCell);
const changedHeights = HeightmapGenerator.getHeights();

View file

@ -1,26 +1,32 @@
"use strict";
function editIce() {
function editIce(element) {
if (customization) return;
if (elSelected && element === elSelected.node()) return;
closeDialogs(".stable");
if (!layerIsOn("toggleIce")) toggleIce();
elSelected = d3.select(d3.event.target);
const type = elSelected.attr("type") ? "Glacier" : "Iceberg";
document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block";
document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block";
if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size");
const id = +elSelected.attr("data-id");
const iceElement = pack.ice.find(el => el.i === id);
const isGlacier = elSelected.attr("type") === "glacier";
const type = isGlacier ? "Glacier" : "Iceberg";
document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block";
document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block";
if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || "";
ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement));
$("#iceEditor").dialog({
title: "Edit " + type,
resizable: false,
position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"},
position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" },
close: closeEditor
});
if (modules.editIce) return;
modules.editIce = true;
// add listeners
document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice"));
document.getElementById("iceRandomize").addEventListener("click", randomizeShape);
@ -28,29 +34,18 @@ function editIce() {
document.getElementById("iceNew").addEventListener("click", toggleAdd);
document.getElementById("iceRemove").addEventListener("click", removeIce);
function randomizeShape() {
const c = grid.points[+elSelected.attr("cell")];
const s = +elSelected.attr("size");
const i = ra(grid.cells.i),
cn = grid.points[i];
const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]);
const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]);
elSelected.attr("points", points);
const selectedId = +elSelected.attr("data-id");
Ice.randomizeIcebergShape(selectedId);
redrawIceberg(selectedId);
}
function changeSize() {
const c = grid.points[+elSelected.attr("cell")];
const s = +elSelected.attr("size");
const flat = elSelected
.attr("points")
.split(",")
.map(el => +el);
const pairs = [];
while (flat.length) pairs.push(flat.splice(0, 2));
const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]);
const size = +this.value;
const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]);
elSelected.attr("points", points).attr("size", size);
const newSize = +this.value;
const selectedId = +elSelected.attr("data-id");
Ice.changeIcebergSize(selectedId, newSize);
redrawIceberg(selectedId);
}
function toggleAdd() {
@ -67,17 +62,15 @@ function editIce() {
function addIcebergOnClick() {
const [x, y] = d3.mouse(this);
const i = findGridCell(x, y, grid);
const [cx, cy] = grid.points[i];
const size = +document.getElementById("iceSize")?.value || 1;
const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]);
const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size);
iceberg.call(d3.drag().on("drag", dragElement));
Ice.addIceberg(i, size);
if (d3.event.shiftKey === false) toggleAdd();
}
function removeIce() {
const type = elSelected.attr("type") ? "Glacier" : "Iceberg";
const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg";
alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`;
$("#alert").dialog({
resizable: false,
@ -85,7 +78,7 @@ function editIce() {
buttons: {
Remove: function () {
$(this).dialog("close");
elSelected.remove();
Ice.removeIce(+elSelected.attr("data-id"));
$("#iceEditor").dialog("close");
},
Cancel: function () {
@ -96,14 +89,24 @@ function editIce() {
}
function dragElement() {
const tr = parseTransform(this.getAttribute("transform"));
const dx = +tr[0] - d3.event.x,
dy = +tr[1] - d3.event.y;
const selectedId = +elSelected.attr("data-id");
const initialTransform = parseTransform(this.getAttribute("transform"));
const dx = initialTransform[0] - d3.event.x;
const dy = initialTransform[1] - d3.event.y;
d3.event.on("drag", function () {
const x = d3.event.x,
y = d3.event.y;
this.setAttribute("transform", `translate(${dx + x},${dy + y})`);
const x = d3.event.x;
const y = d3.event.y;
const transform = `translate(${dx + x},${dy + y})`;
this.setAttribute("transform", transform);
// Update data model with new position
const offset = [dx + x, dy + y];
const iceData = pack.ice.find(element => element.i === selectedId);
if (iceData) {
// Store offset for visual positioning, actual geometry stays in points
iceData.offset = offset;
}
});
}
@ -114,3 +117,4 @@ function editIce() {
unselect();
}
}

View file

@ -417,49 +417,6 @@ function toggleIce(event) {
}
}
function drawIce() {
TIME && console.time("drawIce");
const {cells, features} = grid;
const {temp, h} = cells;
Math.random = aleaPRNG(seed);
const ICEBERG_MAX_TEMP = 0;
const GLACIER_MAX_TEMP = -8;
const minMaxTemp = d3.min(temp);
// cold land: draw glaciers
{
const type = "iceShield";
const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null);
const isolines = getIsolines(grid, getType, {polygons: true});
isolines[type]?.polygons?.forEach(points => {
const clipped = clipPoly(points);
ice.append("polygon").attr("points", clipped).attr("type", type);
});
}
// cold water: draw icebergs
for (const cellId of grid.cells.i) {
const t = temp[cellId];
if (h[cellId] >= 20) continue; // no icebergs on land
if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs
if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes
if (P(0.8)) continue; // skip most of eligible cells
const randomFactor = 0.8 + rand() * 0.4; // random size factor
let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size
if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs
const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1);
const [cx, cy] = grid.points[cellId];
const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]);
ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size);
}
TIME && console.timeEnd("drawIce");
}
function toggleCultures(event) {
const cultures = pack.cultures.filter(c => c.i && !c.removed);
const empty = !cults.selectAll("path").size();

View file

@ -555,7 +555,7 @@ function regenerateMilitary() {
function regenerateIce() {
if (!layerIsOn("toggleIce")) toggleIce();
ice.selectAll("*").remove();
Ice.generate();
drawIce();
}

View file

@ -13,7 +13,7 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
const VERSION = "1.110.0";
const VERSION = "1.111.0";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{

View file

@ -8493,6 +8493,7 @@
<script defer src="config/heightmap-templates.js"></script>
<script defer src="config/precreated-heightmaps.js"></script>
<script defer src="modules/ice.js?v=1.111.0"></script>
<script defer src="modules/names-generator.js?v=1.106.0"></script>
<script defer src="modules/cultures-generator.js?v=1.106.0"></script>
<script defer src="modules/burgs-generator.js?v=1.109.5"></script>
@ -8510,16 +8511,16 @@
<script defer src="libs/lineclip.min.js?v1.105.0"></script>
<script defer src="libs/simplify.js?v1.105.6"></script>
<script defer src="modules/fonts.js?v=1.99.03"></script>
<script defer src="modules/ui/layers.js?v=1.108.4"></script>
<script defer src="modules/ui/layers.js?v=1.111.0"></script>
<script defer src="modules/ui/measurers.js?v=1.99.00"></script>
<script defer src="modules/ui/style-presets.js?v=1.100.00"></script>
<script defer src="modules/ui/general.js?v=1.100.00"></script>
<script defer src="modules/ui/options.js?v=1.106.2"></script>
<script defer src="main.js?v=1.108.1"></script>
<script defer src="main.js?v=1.111.0"></script>
<script defer src="modules/ui/style.js?v=1.108.4"></script>
<script defer src="modules/ui/editors.js?v=1.108.5"></script>
<script defer src="modules/ui/tools.js?v=1.108.5"></script>
<script defer src="modules/ui/editors.js?v=1.111.0"></script>
<script defer src="modules/ui/tools.js?v=1.111.0"></script>
<script defer src="modules/ui/world-configurator.js?v=1.105.4"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.105.2"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.108.1"></script>
@ -8530,7 +8531,7 @@
<script defer src="modules/ui/routes-editor.js?v=1.104.3"></script>
<script defer src="modules/ui/routes-creator.js?v=1.104.3"></script>
<script defer src="modules/ui/route-group-editor.js?v=1.103.8"></script>
<script defer src="modules/ui/ice-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/ice-editor.js?v=1.111.0"></script>
<script defer src="modules/ui/lakes-editor.js?v=1.106.0"></script>
<script defer src="modules/ui/coastline-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/labels-editor.js?v=1.106.0"></script>
@ -8544,12 +8545,12 @@
<script defer src="modules/ui/ai-generator.js?v=1.108.8"></script>
<script defer src="modules/ui/diplomacy-editor.js?v=1.99.00"></script>
<script defer src="modules/ui/zones-editor.js?v=1.105.20"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.110.0"></script>
<script defer src="modules/ui/routes-overview.js?v=1.110.0"></script>
<script defer src="modules/ui/rivers-overview.js?v=1.110.0"></script>
<script defer src="modules/ui/burgs-overview.js?v=1.111.0"></script>
<script defer src="modules/ui/routes-overview.js?v=1.111.0"></script>
<script defer src="modules/ui/rivers-overview.js?v=1.111.0"></script>
<script defer src="modules/ui/military-overview.js?v=1.108.5"></script>
<script defer src="modules/ui/regiments-overview.js?v=1.108.5"></script>
<script defer src="modules/ui/markers-overview.js?v=1.110.0"></script>
<script defer src="modules/ui/markers-overview.js?v=1.111.0"></script>
<script defer src="modules/ui/regiment-editor.js?v=1.108.5"></script>
<script defer src="modules/ui/battle-screen.js?v=1.108.5"></script>
<script defer src="modules/ui/emblems-editor.js?v=1.99.00"></script>
@ -8561,8 +8562,8 @@
<script defer src="modules/coa-renderer.js?v=1.99.00"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
<script defer src="modules/io/save.js?v=1.107.4"></script>
<script defer src="modules/io/load.js?v=1.109.4"></script>
<script defer src="modules/io/save.js?v=1.111.0"></script>
<script defer src="modules/io/load.js?v=1.111.0"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.108.13"></script>
@ -8578,5 +8579,6 @@
<script defer src="modules/renderers/draw-burg-labels.js?v=1.109.4"></script>
<script defer src="modules/renderers/draw-burg-icons.js?v=1.109.4"></script>
<script defer src="modules/renderers/draw-relief-icons.js?v=1.108.4"></script>
<script defer src="modules/renderers/draw-ice.js?v=1.111.0"></script>
</body>
</html>

View file

@ -560,9 +560,7 @@ class HeightmapGenerator {
if(!ctx) {
throw new Error("Could not get canvas context");
}
if(!this.heights) {
throw new Error("Heights array is not initialized");
}
this.heights = this.heights || new Uint8Array(cellsX * cellsY);
ctx.drawImage(img, 0, 0, cellsX, cellsY);
const imageData = ctx.getImageData(0, 0, cellsX, cellsY);
this.setGraph(graph);

View file

@ -1,4 +1,4 @@
import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3";
import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffler } from "d3";
/**
* Convert RGB or RGBA color to HEX
@ -35,11 +35,14 @@ export const C_12 = [
/**
* 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()))
);

View file

@ -206,13 +206,109 @@ export const findClosestCell = (x: number, y: number, radius = Infinity, packedG
return found ? found[2] : undefined;
}
/**
* Searches a quadtree for all points within a given radius
* Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
* @param {number} x - The x coordinate of the search center
* @param {number} y - The y coordinate of the search center
* @param {number} radius - The search radius
* @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) => {
const radiusSearchInit = (t: any, radius: number) => {
t.result = [];
(t.x0 = t.x - radius), (t.y0 = t.y - radius);
(t.x3 = t.x + radius), (t.y3 = t.y + radius);
t.radius = radius * radius;
};
const radiusSearchVisit = (t: any, d2: number) => {
t.node.data.scanned = true;
if (d2 < t.radius) {
do {
t.result.push(t.node.data);
t.node.data.selected = true;
} while ((t.node = t.node.next));
}
};
class Quad {
node: any;
x0: number;
y0: number;
x1: number;
y1: number;
constructor(node: any, x0: number, y0: number, x1: number, y1: number) {
this.node = node;
this.x0 = x0;
this.y0 = y0;
this.x1 = x1;
this.y1 = y1;
}
}
const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root};
if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
radiusSearchInit(t, radius);
var i = 0;
while ((t.q = t.quads.pop())) {
i++;
// Stop searching if this quadrant can't contain a closer node.
if (
!(t.node = t.q.node) ||
(t.x1 = t.q.x0) > t.x3 ||
(t.y1 = t.q.y0) > t.y3 ||
(t.x2 = t.q.x1) < t.x0 ||
(t.y2 = t.q.y1) < t.y0
)
continue;
// Bisect the current quadrant.
if (t.node.length) {
t.node.explored = true;
var xm: number = (t.x1 + t.x2) / 2,
ym: number = (t.y1 + t.y2) / 2;
t.quads.push(
new Quad(t.node[3], xm, ym, t.x2, t.y2),
new Quad(t.node[2], t.x1, ym, xm, t.y2),
new Quad(t.node[1], xm, t.y1, t.x2, ym),
new Quad(t.node[0], t.x1, t.y1, xm, ym)
);
// Visit the closest quadrant first.
if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) {
t.q = t.quads[t.quads.length - 1];
t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
t.quads[t.quads.length - 1 - t.i] = t.q;
}
}
// Visit this point. (Visiting coincident points 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;
radiusSearchVisit(t, d2);
}
}
return t.result;
}
/**
* Returns an array of packed cell indexes within a specified radius from given x and y coordinates
* @param {number} x - The x coordinate
* @param {number} y - The y coordinate
* @param {number} radius - The search radius
* @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[] => {
const found = packedGraph.cells.q.findAll(x, y, radius);
// 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]);
}
@ -325,88 +421,6 @@ export const isWater = (i: number, packedGraph: any) => {
return packedGraph.cells.h[i] < 20;
}
export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => {
const radiusSearchInit = (t: any, radius: number) => {
t.result = [];
(t.x0 = t.x - radius), (t.y0 = t.y - radius);
(t.x3 = t.x + radius), (t.y3 = t.y + radius);
t.radius = radius * radius;
};
const radiusSearchVisit = (t: any, d2: number) => {
t.node.data.scanned = true;
if (d2 < t.radius) {
do {
t.result.push(t.node.data);
t.node.data.selected = true;
} while ((t.node = t.node.next));
}
};
class Quad {
node: any;
x0: number;
y0: number;
x1: number;
y1: number;
constructor(node: any, x0: number, y0: number, x1: number, y1: number) {
this.node = node;
this.x0 = x0;
this.y0 = y0;
this.x1 = x1;
this.y1 = y1;
}
}
const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root};
if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
radiusSearchInit(t, radius);
var i = 0;
while ((t.q = t.quads.pop())) {
i++;
// Stop searching if this quadrant cant contain a closer node.
if (
!(t.node = t.q.node) ||
(t.x1 = t.q.x0) > t.x3 ||
(t.y1 = t.q.y0) > t.y3 ||
(t.x2 = t.q.x1) < t.x0 ||
(t.y2 = t.q.y1) < t.y0
)
continue;
// Bisect the current quadrant.
if (t.node.length) {
t.node.explored = true;
var xm: number = (t.x1 + t.x2) / 2,
ym: number = (t.y1 + t.y2) / 2;
t.quads.push(
new Quad(t.node[3], xm, ym, t.x2, t.y2),
new Quad(t.node[2], t.x1, ym, xm, t.y2),
new Quad(t.node[1], xm, t.y1, t.x2, ym),
new Quad(t.node[0], t.x1, t.y1, xm, ym)
);
// Visit the closest quadrant first.
if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) {
t.q = t.quads[t.quads.length - 1];
t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
t.quads[t.quads.length - 1 - t.i] = t.q;
}
}
// Visit this point. (Visiting coincident points isnt necessary!)
else {
var dx = x - +quadtree._x.call(null, t.node.data),
dy = y - +quadtree._y.call(null, t.node.data),
d2 = dx * dx + dy * dy;
radiusSearchVisit(t, d2);
}
}
return t.result;
}
// draw raster heightmap preview (not used in main generation)
/**

View file

@ -38,6 +38,7 @@ export const each = (n: number) => {
/**
* Random Gaussian number generator
* Uses randomNormal.source(Math.random) to ensure it uses the current PRNG
* @param {number} expected - expected value
* @param {number} deviation - standard deviation
* @param {number} min - minimum value
@ -46,7 +47,8 @@ export const each = (n: number) => {
* @return {number} random number
*/
export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => {
return rn(minmax(randomNormal(expected, deviation)(), min, max), round);
// 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);
}
/**

View file

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

View file

@ -6,7 +6,7 @@ import { rn } from "./numberUtils";
* @param {number} decimals - Number of decimal places (default is 1)
* @returns {string} - The string with rounded numbers
*/
export const round = (inputString: string, decimals: number = 1) => {
export const round = (inputString: string = "", decimals: number = 1) => {
return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => {
return rn(parseFloat(n), decimals).toString();
});

241
tests/e2e/layers.spec.ts Normal file
View file

@ -0,0 +1,241 @@
import { test, expect } from '@playwright/test'
test.describe('map layers', () => {
test.beforeEach(async ({ context, page }) => {
// Clear all storage to ensure clean state
await context.clearCookies()
await page.goto('/')
await page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
})
// Navigate with seed parameter and wait for full load
// NOTE:
// - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests.
// - Snapshots are OS-independent (configured in playwright.config.ts).
await page.goto('/?seed=test-seed&&width=1280&height=720')
// Wait for map generation to complete by checking window.mapId
// mapId is exposed on window at the very end of showStatistics()
await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 60000 })
// Additional wait for any rendering/animations to settle
await page.waitForTimeout(500)
})
// Ocean and water layers
test('ocean layer', async ({ page }) => {
const ocean = page.locator('#ocean')
await expect(ocean).toBeAttached()
const html = await ocean.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('ocean.html')
})
test('lakes layer', async ({ page }) => {
const lakes = page.locator('#lakes')
await expect(lakes).toBeAttached()
const html = await lakes.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('lakes.html')
})
test('coastline layer', async ({ page }) => {
const coastline = page.locator('#coastline')
await expect(coastline).toBeAttached()
const html = await coastline.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('coastline.html')
})
// Terrain and heightmap layers
test('terrain layer', async ({ page }) => {
const terrs = page.locator('#terrs')
await expect(terrs).toBeAttached()
const html = await terrs.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('terrain.html')
})
test('landmass layer', async ({ page }) => {
const landmass = page.locator('#landmass')
await expect(landmass).toBeAttached()
const html = await landmass.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('landmass.html')
})
// Climate and environment layers
test('biomes layer', async ({ page }) => {
const biomes = page.locator('#biomes')
await expect(biomes).toBeAttached()
const html = await biomes.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('biomes.html')
})
test('ice layer', async ({ page }) => {
const ice = page.locator('#ice')
await expect(ice).toBeAttached()
const html = await ice.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('ice.html')
})
test('temperature layer', async ({ page }) => {
const temperature = page.locator('#temperature')
await expect(temperature).toBeAttached()
const html = await temperature.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('temperature.html')
})
test('precipitation layer', async ({ page }) => {
const prec = page.locator('#prec')
await expect(prec).toBeAttached()
const html = await prec.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('precipitation.html')
})
// Geographic features
test('rivers layer', async ({ page }) => {
const rivers = page.locator('#rivers')
await expect(rivers).toBeAttached()
const html = await rivers.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('rivers.html')
})
test('relief layer', async ({ page }) => {
const terrain = page.locator('#terrain')
await expect(terrain).toBeAttached()
const html = await terrain.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('relief.html')
})
// Political layers
test('states/regions layer', async ({ page }) => {
const regions = page.locator('#regions')
await expect(regions).toBeAttached()
const html = await regions.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('regions.html')
})
test('provinces layer', async ({ page }) => {
const provs = page.locator('#provs')
await expect(provs).toBeAttached()
const html = await provs.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('provinces.html')
})
test('borders layer', async ({ page }) => {
const borders = page.locator('#borders')
await expect(borders).toBeAttached()
const html = await borders.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('borders.html')
})
// Cultural layers
test('cultures layer', async ({ page }) => {
const cults = page.locator('#cults')
await expect(cults).toBeAttached()
const html = await cults.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('cultures.html')
})
test('religions layer', async ({ page }) => {
const relig = page.locator('#relig')
await expect(relig).toBeAttached()
const html = await relig.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('religions.html')
})
// Infrastructure layers
test('routes layer', async ({ page }) => {
const routes = page.locator('#routes')
await expect(routes).toBeAttached()
const html = await routes.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('routes.html')
})
// Settlement layers
test('burgs/icons layer', async ({ page }) => {
const icons = page.locator('#icons')
await expect(icons).toBeAttached()
const html = await icons.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('icons.html')
})
test('anchors layer', async ({ page }) => {
const anchors = page.locator('#anchors')
await expect(anchors).toBeAttached()
const html = await anchors.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('anchors.html')
})
// Labels layer (without text content due to font rendering)
test('labels layer', async ({ page }) => {
const labels = page.locator('#labels')
await expect(labels).toBeAttached()
// Remove text content but keep structure (text rendering varies)
const html = await labels.evaluate((el) => {
const clone = el.cloneNode(true) as Element
clone.querySelectorAll('text, tspan').forEach((t) => t.remove())
return clone.outerHTML
})
expect(html).toMatchSnapshot('labels.html')
})
// Military and markers
test('markers layer', async ({ page }) => {
const markers = page.locator('#markers')
await expect(markers).toBeAttached()
const html = await markers.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('markers.html')
})
test('armies layer', async ({ page }) => {
const armies = page.locator('#armies')
await expect(armies).toBeAttached()
const html = await armies.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('armies.html')
})
// Special features
test('zones layer', async ({ page }) => {
const zones = page.locator('#zones')
await expect(zones).toBeAttached()
const html = await zones.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('zones.html')
})
test('emblems layer', async ({ page }) => {
const emblems = page.locator('#emblems')
await expect(emblems).toBeAttached()
const html = await emblems.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('emblems.html')
})
// Grid and coordinates
test('cells layer', async ({ page }) => {
const cells = page.locator('g#cells')
await expect(cells).toBeAttached()
const html = await cells.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('cells.html')
})
test('coordinates layer', async ({ page }) => {
const coordinates = page.locator('#coordinates')
await expect(coordinates).toBeAttached()
const html = await coordinates.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('coordinates.html')
})
test('compass layer', async ({ page }) => {
const compass = page.locator('#compass')
await expect(compass).toBeAttached()
const html = await compass.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('compass.html')
})
// Population layer
test('population layer', async ({ page }) => {
const population = page.locator('#population')
await expect(population).toBeAttached()
const html = await population.evaluate((el) => el.outerHTML)
expect(html).toMatchSnapshot('population.html')
})
})

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="armies" font-size="6" box-size="3" stroke="#000" stroke-width="0.3" fill-opacity="1"></g>

View file

@ -0,0 +1 @@
<g id="biomes" mask="url(#land)"></g>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="cells" stroke="#808080" stroke-width="0.1"></g>

View file

@ -0,0 +1 @@
<g id="coastline"><g id="sea_island" opacity="0.5" stroke="#1f3846" stroke-width="0.7" filter="url(#dropShadow)" auto-filter="1" style="filter: none;"><use href="#feature_2" data-f="2"></use><use href="#feature_3" data-f="3"></use><use href="#feature_4" data-f="4"></use><use href="#feature_5" data-f="5"></use><use href="#feature_6" data-f="6"></use><use href="#feature_15" data-f="15"></use></g><g id="lake_island" opacity="1" stroke="#7c8eaf" stroke-width="0.35"></g></g>

View file

@ -0,0 +1 @@
<g id="compass" opacity="0.8" mask="url(#water)" shape-rendering="optimizespeed" style="display: none;"><use xlink:href="#defs-compass-rose" transform="translate(80 80) scale(0.25)"></use></g>

View file

@ -0,0 +1 @@
<g id="coordinates" opacity="1" data-size="12" font-size="12" stroke="#d4d4d4" stroke-width="1" stroke-dasharray="5"></g>

View file

@ -0,0 +1 @@
<g id="cults" opacity="0.6" stroke="#777777" stroke-width="0.5"></g>

View file

@ -0,0 +1 @@
<g id="emblems" opacity="0.9" stroke-width="1" style="display: none;"><g id="burgEmblems" data-size="1"></g><g id="provinceEmblems" data-size="1"></g><g id="stateEmblems" data-size="1"></g></g>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="labels" style="display: inline;"><g id="states" opacity="1" fill="#3e3e4b" stroke="#3a3a3a" stroke-width="0" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="22" font-size="22" font-family="Almendra SC"></g><g id="addedLabels" opacity="1" fill="#3e3e4b" stroke="#3a3a3a" stroke-width="0" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="18" font-size="18" font-family="Almendra SC"></g><g id="burgLabels"><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="2" font-size="2" font-family="Almendra SC" data-dy="-0.4" id="hamlet" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="3" font-size="3" font-family="Almendra SC" data-dy="-0.4" id="village" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="2" font-size="2" font-family="Almendra SC" data-dy="-0.5" id="trading_post" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="2" font-size="2" font-family="Almendra SC" data-dy="-0.5" id="caravanserai" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="2" font-size="2" font-family="Almendra SC" data-dy="-0.5" id="monastery" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="2" font-size="2" font-family="Almendra SC" data-dy="-0.5" id="fort" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="4" font-size="4" font-family="Almendra SC" data-dy="-0.4" id="town" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="5" font-size="5" font-family="Almendra SC" data-dy="-0.4" id="city" class="hidden"></g><g opacity="1" fill="#3e3e4b" style="text-shadow: white 0px 0px 4px" letter-spacing="0" data-size="6" font-size="6" font-family="Almendra SC" data-dy="-0.5" id="capital"></g></g></g>

View file

@ -0,0 +1 @@
<g id="lakes"><g id="freshwater" opacity="0.5" fill="#a6c1fd" stroke="#5f799d" stroke-width="0.7"><use href="#feature_7" data-f="7"></use><use href="#feature_8" data-f="8"></use><use href="#feature_9" data-f="9"></use><use href="#feature_10" data-f="10"></use><use href="#feature_11" data-f="11"></use><use href="#feature_12" data-f="12"></use><use href="#feature_13" data-f="13"></use><use href="#feature_14" data-f="14"></use></g><g id="salt" opacity="0.5" fill="#409b8a" stroke="#388985" stroke-width="0.7"></g><g id="sinkhole" opacity="1" fill="#5bc9fd" stroke="#53a3b0" stroke-width="0.7"></g><g id="frozen" opacity="0.95" fill="#cdd4e7" stroke="#cfe0eb" stroke-width="0"></g><g id="lava" opacity="0.7" fill="#90270d" stroke="#f93e0c" stroke-width="2" filter="url(#crumpled)"></g><g id="dry" opacity="1" fill="#c9bfa7" stroke="#8e816f" stroke-width="0.7"></g></g>

View file

@ -0,0 +1 @@
<g id="landmass" opacity="1" fill="#eef6fb"><rect x="0" y="0" width="1280" height="720"></rect></g>

View file

@ -0,0 +1 @@
<g id="markers" rescale="1"></g>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="population" stroke-width="1.6" stroke-linecap="butt"><g id="rural" stroke="#0000ff"></g><g id="urban" stroke="#ff0000"></g></g>

View file

@ -0,0 +1 @@
<g id="prec" stroke="#000000" stroke-width="0" fill="#003dff" style="display: none;"><g id="wind"><text text-rendering="optimizeSpeed" x="1228" y="60.904999999999994"></text><text text-rendering="optimizeSpeed" x="20" y="187.95"></text><text text-rendering="optimizeSpeed" x="1228" y="305.625"></text><text text-rendering="optimizeSpeed" x="1228" y="428.15"></text><text text-rendering="optimizeSpeed" x="20" y="547.8299999999999"></text><text text-rendering="optimizeSpeed" x="1228" y="661.2449999999999"></text><text text-rendering="optimizeSpeed" x="640" y="42"></text><text text-rendering="optimizeSpeed" x="640" y="700"></text></g></g>

View file

@ -0,0 +1 @@
<g id="provs" opacity="0.7" fill="#000000" font-size="10" font-family="Georgia"></g>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="terrain" set="simple" size="1" density="0.4"></g>

View file

@ -0,0 +1 @@
<g id="relig" opacity="0.7" stroke="#777777" stroke-width="0"></g>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<g id="temperature" font-size="8px" fill="#000000" fill-opacity="0.3" stroke-width="1.8"></g>

View file

@ -0,0 +1 @@
<g id="terrs"><g id="oceanHeights" data-render="0" opacity="1" scheme="bright" terracing="0" skip="0" relax="1" curve="curveBasisClosed"></g><g id="landHeights" opacity="1" scheme="bright" terracing="0" skip="5" relax="0" curve="curveBasisClosed" mask="url(#land)"></g></g>

View file

@ -0,0 +1 @@
<g id="zones" opacity="0.6" stroke="#333333" stroke-width="0" stroke-linecap="butt"></g>

View file

@ -22,5 +22,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/e2e"]
}

18
vitest.browser.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
// https://vitest.dev/config/browser/playwright
instances: [
{ name: 'chromium', browser: 'chromium' },
],
locators: {
testIdAttribute: 'id',
},
},
},
})