diff --git a/package-lock.json b/package-lock.json
index 31708dfc..225d4ade 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,17 @@
"name": "fantasy-map-generator",
"version": "1.109.5",
"license": "MIT",
+ "dependencies": {
+ "alea": "^1.0.1",
+ "d3": "^7.9.0",
+ "delaunator": "^5.0.1",
+ "polylabel": "^2.0.1"
+ },
"devDependencies": {
+ "@types/d3": "^7.4.3",
+ "@types/delaunator": "^5.0.3",
+ "@types/polylabel": "^1.1.3",
+ "typescript": "^5.9.3",
"vite": "^7.3.1"
}
},
@@ -804,6 +814,297 @@
"win32"
]
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/delaunator": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/delaunator/-/delaunator-5.0.3.tgz",
+ "integrity": "sha512-6tTLP8NX0OwtB/fmW9bXp4EWPptawTSsrSGjboWRuzqkxNEEJGyzRPHbr8wnV2DBWfAZ+EPTOvW3B/KysJrl2g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -811,6 +1112,446 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/polylabel": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
+ "integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/delaunator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -886,6 +1627,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -918,6 +1680,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -925,6 +1688,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/polylabel": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz",
+ "integrity": "sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==",
+ "license": "ISC",
+ "dependencies": {
+ "tinyqueue": "^3.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -954,6 +1726,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "license": "Unlicense"
+ },
"node_modules/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
@@ -999,6 +1777,18 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1026,6 +1816,26 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
+ "license": "ISC"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
diff --git a/package.json b/package.json
index 1e222bb5..cadffbd4 100644
--- a/package.json
+++ b/package.json
@@ -15,12 +15,22 @@
"main": "main.js",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
+ "@types/d3": "^7.4.3",
+ "@types/delaunator": "^5.0.3",
+ "@types/polylabel": "^1.1.3",
+ "typescript": "^5.9.3",
"vite": "^7.3.1"
},
+ "dependencies": {
+ "alea": "^1.0.1",
+ "d3": "^7.9.0",
+ "delaunator": "^5.0.1",
+ "polylabel": "^2.0.1"
+ },
"engines": {
"node": ">=24.0.0"
}
diff --git a/public/main.js b/public/main.js
index 29760109..6da462d5 100644
--- a/public/main.js
+++ b/public/main.js
@@ -13,12 +13,6 @@ const ERROR = true;
// detect device
const MOBILE = window.innerWidth < 600 || navigator.userAgentData?.mobile;
-// typed arrays max values
-const INT8_MAX = 127;
-const UINT8_MAX = 255;
-const UINT16_MAX = 65535;
-const UINT32_MAX = 4294967295;
-
if (PRODUCTION && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("./sw.js").catch(err => {
@@ -91,7 +85,7 @@ let fogging = viewbox
.attr("id", "fogging")
.style("display", "none");
let ruler = viewbox.append("g").attr("id", "ruler").style("display", "none");
-let debug = viewbox.append("g").attr("id", "debug");
+var debug = viewbox.append("g").attr("id", "debug");
lakes.append("g").attr("id", "freshwater");
lakes.append("g").attr("id", "salt");
@@ -140,9 +134,9 @@ legend
.on("click", () => clearLegend());
// main data variables
-let grid = {}; // initial graph based on jittered square grid and data
-let pack = {}; // packed graph and data
-let seed;
+var grid = {}; // initial graph based on jittered square grid and data
+var pack = {}; // packed graph and data
+var seed;
let mapId;
let mapHistory = [];
let elSelected;
@@ -202,8 +196,8 @@ let urbanDensity = +byId("urbanDensityInput").value;
applyStoredOptions();
// voronoi graph extension, cannot be changed after generation
-let graphWidth = +mapWidthInput.value;
-let graphHeight = +mapHeightInput.value;
+var graphWidth = +mapWidthInput.value;
+var graphHeight = +mapHeightInput.value;
// svg canvas resolution, can be changed
let svgWidth = graphWidth;
diff --git a/public/modules/military-generator.js b/public/modules/military-generator.js
index 5aea87db..34c705d8 100644
--- a/public/modules/military-generator.js
+++ b/public/modules/military-generator.js
@@ -271,7 +271,7 @@ window.Military = (function () {
}
if (node.t > expected) return;
const r = (expected - node.t) / (node.s ? 40 : 20); // search radius
- const candidates = tree.findAll(node.x, node.y, r);
+ const candidates = findAllInQuadtree(node.x, node.y, r, tree);
for (const c of candidates) {
if (c.t < expected && mergeable(node, c)) {
merge(node, c);
diff --git a/public/modules/ui/relief-editor.js b/public/modules/ui/relief-editor.js
index abb800cd..44a2c727 100644
--- a/public/modules/ui/relief-editor.js
+++ b/public/modules/ui/relief-editor.js
@@ -201,7 +201,7 @@ function editReliefIcon() {
d3.event.on("drag", function () {
const p = d3.mouse(this);
moveCircle(p[0], p[1], r);
- tree.findAll(p[0], p[1], r).forEach(f => f[2].remove());
+ findAllInQuadtree(p[0], p[1], r, tree).forEach(f => f[2].remove());
});
}
diff --git a/public/modules/voronoi.js b/public/modules/voronoi.js
index 19581c9d..6c504014 100644
--- a/public/modules/voronoi.js
+++ b/public/modules/voronoi.js
@@ -133,3 +133,5 @@ class Voronoi {
];
}
}
+
+window.Voronoi = Voronoi;
diff --git a/public/utils/arrayUtils.js b/public/utils/arrayUtils.js
deleted file mode 100644
index d872d086..00000000
--- a/public/utils/arrayUtils.js
+++ /dev/null
@@ -1,60 +0,0 @@
-"use strict";
-
-function last(array) {
- return array[array.length - 1];
-}
-
-function unique(array) {
- return [...new Set(array)];
-}
-
-// deep copy for Arrays (and other objects)
-function deepCopy(obj) {
- const id = x => x;
- const dcTArray = a => a.map(id);
- const dcObject = x => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)]));
- const dcAny = x => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x);
- // don't map keys, probably this is what we would expect
- const dcMapCore = m => [...m.entries()].map(([k, v]) => [k, dcAny(v)]);
-
- const cf = new Map([
- [Int8Array, dcTArray],
- [Uint8Array, dcTArray],
- [Uint8ClampedArray, dcTArray],
- [Int16Array, dcTArray],
- [Uint16Array, dcTArray],
- [Int32Array, dcTArray],
- [Uint32Array, dcTArray],
- [Float32Array, dcTArray],
- [Float64Array, dcTArray],
- [BigInt64Array, dcTArray],
- [BigUint64Array, dcTArray],
- [Map, m => new Map(dcMapCore(m))],
- [WeakMap, m => new WeakMap(dcMapCore(m))],
- [Array, a => a.map(dcAny)],
- [Set, s => [...s.values()].map(dcAny)],
- [Date, d => new Date(d.getTime())],
- [Object, dcObject]
- // ... extend here to implement their custom deep copy
- ]);
-
- return dcAny(obj);
-}
-
-function getTypedArray(maxValue) {
- console.assert(
- Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= UINT32_MAX,
- `Array maxValue must be an integer between 0 and ${UINT32_MAX}, got ${maxValue}`
- );
-
- if (maxValue <= UINT8_MAX) return Uint8Array;
- if (maxValue <= UINT16_MAX) return Uint16Array;
- if (maxValue <= UINT32_MAX) return Uint32Array;
- return Uint32Array;
-}
-
-function createTypedArray({maxValue, length, from}) {
- const typedArray = getTypedArray(maxValue);
- if (!from) return new typedArray(length);
- return typedArray.from(from);
-}
diff --git a/public/utils/colorUtils.js b/public/utils/colorUtils.js
deleted file mode 100644
index b96cd79c..00000000
--- a/public/utils/colorUtils.js
+++ /dev/null
@@ -1,49 +0,0 @@
-"use strict";
-// FMG utils related to colors
-
-// convert RGB color string to HEX without #
-function toHEX(rgb) {
- if (rgb.charAt(0) === "#") return rgb;
-
- rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
- return rgb && rgb.length === 4
- ? "#" +
- ("0" + parseInt(rgb[1], 10).toString(16)).slice(-2) +
- ("0" + parseInt(rgb[2], 10).toString(16)).slice(-2) +
- ("0" + parseInt(rgb[3], 10).toString(16)).slice(-2)
- : "";
-}
-
-const C_12 = [
- "#dababf",
- "#fb8072",
- "#80b1d3",
- "#fdb462",
- "#b3de69",
- "#fccde5",
- "#c6b9c1",
- "#bc80bd",
- "#ccebc5",
- "#ffed6f",
- "#8dd3c7",
- "#eb8de7"
-];
-const scaleRainbow = d3.scaleSequential(d3.interpolateRainbow);
-
-// return array of standard shuffled colors
-function getColors(number) {
- const colors = d3.shuffle(
- d3.range(number).map(i => (i < 12 ? C_12[i] : d3.color(scaleRainbow((i - 12) / (number - 12))).hex()))
- );
- return colors;
-}
-
-function getRandomColor() {
- return d3.color(d3.scaleSequential(d3.interpolateRainbow)(Math.random())).hex();
-}
-
-// mix a color with a random color
-function getMixedColor(color, mix = 0.2, bright = 0.3) {
- const c = color && color[0] === "#" ? color : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one
- return d3.color(d3.interpolate(c, getRandomColor())(mix)).brighter(bright).hex();
-}
diff --git a/public/utils/commonUtils.js b/public/utils/commonUtils.js
deleted file mode 100644
index 58f3f0be..00000000
--- a/public/utils/commonUtils.js
+++ /dev/null
@@ -1,191 +0,0 @@
-"use strict";
-// FMG helper functions
-
-// clip polygon by graph bbox
-function clipPoly(points, secure = 0) {
- if (points.length < 2) return points;
- if (points.some(point => point === undefined)) {
- ERROR && console.error("Undefined point in clipPoly", points);
- return points;
- }
-
- return polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
-}
-
-// get segment of any point on polyline
-function getSegmentId(points, point, step = 10) {
- if (points.length === 2) return 1;
-
- let minSegment = 1;
- let minDist = Infinity;
-
- for (let i = 0; i < points.length - 1; i++) {
- const p1 = points[i];
- const p2 = points[i + 1];
-
- const length = Math.sqrt(dist2(p1, p2));
- const segments = Math.ceil(length / step);
- const dx = (p2[0] - p1[0]) / segments;
- const dy = (p2[1] - p1[1]) / segments;
-
- for (let s = 0; s < segments; s++) {
- const x = p1[0] + s * dx;
- const y = p1[1] + s * dy;
- const dist = dist2(point, [x, y]);
-
- if (dist >= minDist) continue;
- minDist = dist;
- minSegment = i + 1;
- }
- }
-
- return minSegment;
-}
-
-function debounce(func, ms) {
- let isCooldown = false;
-
- return function () {
- if (isCooldown) return;
- func.apply(this, arguments);
- isCooldown = true;
- setTimeout(() => (isCooldown = false), ms);
- };
-}
-
-function throttle(func, ms) {
- let isThrottled = false;
- let savedArgs;
- let savedThis;
-
- function wrapper() {
- if (isThrottled) {
- savedArgs = arguments;
- savedThis = this;
- return;
- }
-
- func.apply(this, arguments);
- isThrottled = true;
-
- setTimeout(function () {
- isThrottled = false;
- if (savedArgs) {
- wrapper.apply(savedThis, savedArgs);
- savedArgs = savedThis = null;
- }
- }, ms);
- }
-
- return wrapper;
-}
-
-// parse error to get the readable string in Chrome and Firefox
-function parseError(error) {
- const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
- const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack;
- const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi;
- const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + "");
- const errorParsed = errorNoURL.replace(/at /gi, "
at ");
- return errorParsed;
-}
-
-function getBase64(url, callback) {
- const xhr = new XMLHttpRequest();
- xhr.onload = function () {
- const reader = new FileReader();
- reader.onloadend = function () {
- callback(reader.result);
- };
- reader.readAsDataURL(xhr.response);
- };
- xhr.open("GET", url);
- xhr.responseType = "blob";
- xhr.send();
-}
-
-// open URL in a new tab or window
-function openURL(url) {
- window.open(url, "_blank");
-}
-
-// open project wiki-page
-function wiki(page) {
- window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank");
-}
-
-// wrap URL into html a element
-function link(URL, description) {
- return `${description}`;
-}
-
-function isCtrlClick(event) {
- // meta key is cmd key on MacOs
- return event.ctrlKey || event.metaKey;
-}
-
-function generateDate(from = 100, to = 1000) {
- return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {
- year: "numeric",
- month: "long",
- day: "numeric"
- });
-}
-
-function getLongitude(x, decimals = 2) {
- return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals);
-}
-
-function getLatitude(y, decimals = 2) {
- return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals);
-}
-
-function getCoordinates(x, y, decimals = 2) {
- return [getLongitude(x, decimals), getLatitude(y, decimals)];
-}
-
-// prompt replacer (prompt does not work in Electron)
-void (function () {
- const prompt = document.getElementById("prompt");
- const form = prompt.querySelector("#promptForm");
-
- const defaultText = "Please provide an input";
- const defaultOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true};
-
- window.prompt = function (promptText = defaultText, options = defaultOptions, callback) {
- if (options.default === undefined)
- return ERROR && console.error("Prompt: options object does not have default value defined");
-
- const input = prompt.querySelector("#promptInput");
- prompt.querySelector("#promptText").innerHTML = promptText;
-
- const type = typeof options.default === "number" ? "number" : "text";
- input.type = type;
-
- if (options.step !== undefined) input.step = options.step;
- if (options.min !== undefined) input.min = options.min;
- if (options.max !== undefined) input.max = options.max;
-
- input.required = options.required === false ? false : true;
- input.placeholder = "type a " + type;
- input.value = options.default;
- input.style.width = promptText.length > 10 ? "100%" : "auto";
- prompt.style.display = "block";
-
- form.addEventListener(
- "submit",
- event => {
- event.preventDefault();
- prompt.style.display = "none";
- const v = type === "number" ? +input.value : input.value;
- if (callback) callback(v);
- },
- {once: true}
- );
- };
-
- const cancel = prompt.querySelector("#promptCancel");
- cancel.addEventListener("click", () => {
- prompt.style.display = "none";
- });
-})();
diff --git a/public/utils/debugUtils.js b/public/utils/debugUtils.js
deleted file mode 100644
index 1859f3ae..00000000
--- a/public/utils/debugUtils.js
+++ /dev/null
@@ -1,72 +0,0 @@
-"use strict";
-// FMG utils used for debugging
-
-function drawCellsValue(data) {
- debug.selectAll("text").remove();
- debug
- .selectAll("text")
- .data(data)
- .enter()
- .append("text")
- .attr("x", (d, i) => pack.cells.p[i][0])
- .attr("y", (d, i) => pack.cells.p[i][1])
- .text(d => d);
-}
-
-function drawPolygons(data) {
- const max = d3.max(data);
- const min = d3.min(data);
- const scheme = getColorScheme(terrs.select("#landHeights").attr("scheme"));
-
- data = data.map(d => 1 - normalize(d, min, max));
-
- debug.selectAll("polygon").remove();
- debug
- .selectAll("polygon")
- .data(data)
- .enter()
- .append("polygon")
- .attr("points", (d, i) => getGridPolygon(i))
- .attr("fill", d => scheme(d))
- .attr("stroke", d => scheme(d));
-}
-
-function drawRouteConnections() {
- debug.select("#connections").remove();
- const routes = debug.append("g").attr("id", "connections").attr("stroke-width", 0.8);
-
- const points = pack.cells.p;
- const links = pack.cells.routes;
-
- for (const from in links) {
- for (const to in links[from]) {
- const [x1, y1] = points[from];
- const [x3, y3] = points[to];
- const [x2, y2] = [(x1 + x3) / 2, (y1 + y3) / 2];
- const routeId = links[from][to];
-
- routes
- .append("line")
- .attr("x1", x1)
- .attr("y1", y1)
- .attr("x2", x2)
- .attr("y2", y2)
- .attr("data-id", routeId)
- .attr("stroke", C_12[routeId % 12]);
- }
- }
-}
-
-function drawPoint([x, y], {color = "red", radius = 0.5}) {
- debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color);
-}
-
-function drawPath(points, {color = "red", width = 0.5}) {
- const lineGen = d3.line().curve(d3.curveBundle);
- debug
- .append("path")
- .attr("d", round(lineGen(points)))
- .attr("stroke", color)
- .attr("stroke-width", width)
- .attr("fill", "none");
-}
diff --git a/public/utils/functionUtils.js b/public/utils/functionUtils.js
deleted file mode 100644
index 12570b40..00000000
--- a/public/utils/functionUtils.js
+++ /dev/null
@@ -1,31 +0,0 @@
-"use strict";
-// FMG helper functions
-
-// extracted d3 code to bypass version conflicts
-// https://github.com/d3/d3-array/blob/main/src/group.js
-function rollups(values, reduce, ...keys) {
- return nest(values, Array.from, reduce, keys);
-}
-
-function nest(values, map, reduce, keys) {
- return (function regroup(values, i) {
- if (i >= keys.length) return reduce(values);
- const groups = new Map();
- const keyof = keys[i++];
- let index = -1;
- for (const value of values) {
- const key = keyof(value, ++index, values);
- const group = groups.get(key);
- if (group) group.push(value);
- else groups.set(key, [value]);
- }
- for (const [key, values] of groups) {
- groups.set(key, regroup(values, i));
- }
- return map(groups);
- })(values, 0);
-}
-
-function dist2([x1, y1], [x2, y2]) {
- return (x1 - x2) ** 2 + (y1 - y2) ** 2;
-}
diff --git a/public/utils/graphUtils.js b/public/utils/graphUtils.js
deleted file mode 100644
index 82577b82..00000000
--- a/public/utils/graphUtils.js
+++ /dev/null
@@ -1,336 +0,0 @@
-"use strict";
-// FMG utils related to graph
-
-// check if new grid graph should be generated or we can use the existing one
-function shouldRegenerateGrid(grid, expectedSeed) {
- if (expectedSeed && expectedSeed !== grid.seed) return true;
-
- const cellsDesired = +byId("pointsInput").dataset.cells;
- if (cellsDesired !== grid.cellsDesired) return true;
-
- const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2);
- const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing);
- const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing);
-
- return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY;
-}
-
-function generateGrid() {
- Math.random = aleaPRNG(seed); // reset PRNG
- const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints();
- const {cells, vertices} = calculateVoronoi(points, boundary);
- return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed};
-}
-
-// place random points to calculate Voronoi diagram
-function placePoints() {
- TIME && console.time("placePoints");
- const cellsDesired = +byId("pointsInput").dataset.cells;
- const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jirrering
-
- const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
- const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid
- const cellsX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing);
- const cellsY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing);
- TIME && console.timeEnd("placePoints");
-
- return {spacing, cellsDesired, boundary, points, cellsX, cellsY};
-}
-
-// calculate Delaunay and then Voronoi diagram
-function calculateVoronoi(points, boundary) {
- TIME && console.time("calculateDelaunay");
- const allPoints = points.concat(boundary);
- const delaunay = Delaunator.from(allPoints);
- TIME && console.timeEnd("calculateDelaunay");
-
- TIME && console.time("calculateVoronoi");
- const voronoi = new Voronoi(delaunay, allPoints, points.length);
-
- const cells = voronoi.cells;
- cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i); // array of indexes
- const vertices = voronoi.vertices;
- TIME && console.timeEnd("calculateVoronoi");
-
- return {cells, vertices};
-}
-
-// add points along map edge to pseudo-clip voronoi cells
-function getBoundaryPoints(width, height, spacing) {
- const offset = rn(-1 * spacing);
- const bSpacing = spacing * 2;
- const w = width - offset * 2;
- const h = height - offset * 2;
- const numberX = Math.ceil(w / bSpacing) - 1;
- const numberY = Math.ceil(h / bSpacing) - 1;
- const points = [];
-
- for (let i = 0.5; i < numberX; i++) {
- let x = Math.ceil((w * i) / numberX + offset);
- points.push([x, offset], [x, h + offset]);
- }
-
- for (let i = 0.5; i < numberY; i++) {
- let y = Math.ceil((h * i) / numberY + offset);
- points.push([offset, y], [w + offset, y]);
- }
-
- return points;
-}
-
-// get points on a regular square grid and jitter them a bit
-function getJitteredGrid(width, height, spacing) {
- const radius = spacing / 2; // square radius
- const jittering = radius * 0.9; // max deviation
- const doubleJittering = jittering * 2;
- const jitter = () => Math.random() * doubleJittering - jittering;
-
- let points = [];
- for (let y = radius; y < height; y += spacing) {
- for (let x = radius; x < width; x += spacing) {
- const xj = Math.min(rn(x + jitter(), 2), width);
- const yj = Math.min(rn(y + jitter(), 2), height);
- points.push([xj, yj]);
- }
- }
- return points;
-}
-
-// return cell index on a regular square grid
-function findGridCell(x, y, grid) {
- return (
- Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX +
- Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1))
- );
-}
-
-// return array of cell indexes in radius on a regular square grid
-function findGridAll(x, y, radius) {
- const c = grid.cells.c;
- let r = Math.floor(radius / grid.spacing);
- let found = [findGridCell(x, y, grid)];
- if (!r || radius === 1) return found;
- if (r > 0) found = found.concat(c[found[0]]);
- if (r > 1) {
- let frontier = c[found[0]];
- while (r > 1) {
- let cycle = frontier.slice();
- frontier = [];
- cycle.forEach(function (s) {
- c[s].forEach(function (e) {
- if (found.indexOf(e) !== -1) return;
- found.push(e);
- frontier.push(e);
- });
- });
- r--;
- }
- }
-
- return found;
-}
-
-// return closest pack points quadtree datum
-function find(x, y, radius = Infinity) {
- return pack.cells.q.find(x, y, radius);
-}
-
-// return closest cell index
-function findCell(x, y, radius = Infinity) {
- if (!pack.cells?.q) return;
- const found = pack.cells.q.find(x, y, radius);
- return found ? found[2] : undefined;
-}
-
-// return array of cell indexes in radius
-function findAll(x, y, radius) {
- const found = pack.cells.q.findAll(x, y, radius);
- return found.map(r => r[2]);
-}
-
-// get polygon points for packed cells knowing cell id
-function getPackPolygon(i) {
- return pack.cells.v[i].map(v => pack.vertices.p[v]);
-}
-
-// get polygon points for initial cells knowing cell id
-function getGridPolygon(i) {
- return grid.cells.v[i].map(v => grid.vertices.p[v]);
-}
-
-// mbostock's poissonDiscSampler
-function* poissonDiscSampler(x0, y0, x1, y1, r, k = 3) {
- if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error();
-
- const width = x1 - x0;
- const height = y1 - y0;
- const r2 = r * r;
- const r2_3 = 3 * r2;
- const cellSize = r * Math.SQRT1_2;
- const gridWidth = Math.ceil(width / cellSize);
- const gridHeight = Math.ceil(height / cellSize);
- const grid = new Array(gridWidth * gridHeight);
- const queue = [];
-
- function far(x, y) {
- const i = (x / cellSize) | 0;
- const j = (y / cellSize) | 0;
- const i0 = Math.max(i - 2, 0);
- const j0 = Math.max(j - 2, 0);
- const i1 = Math.min(i + 3, gridWidth);
- const j1 = Math.min(j + 3, gridHeight);
- for (let j = j0; j < j1; ++j) {
- const o = j * gridWidth;
- for (let i = i0; i < i1; ++i) {
- const s = grid[o + i];
- if (s) {
- const dx = s[0] - x;
- const dy = s[1] - y;
- if (dx * dx + dy * dy < r2) return false;
- }
- }
- }
- return true;
- }
-
- function sample(x, y) {
- queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = [x, y]));
- return [x + x0, y + y0];
- }
-
- yield sample(width / 2, height / 2);
-
- pick: while (queue.length) {
- const i = (Math.random() * queue.length) | 0;
- const parent = queue[i];
-
- for (let j = 0; j < k; ++j) {
- const a = 2 * Math.PI * Math.random();
- const r = Math.sqrt(Math.random() * r2_3 + r2);
- const x = parent[0] + r * Math.cos(a);
- const y = parent[1] + r * Math.sin(a);
- if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) {
- yield sample(x, y);
- continue pick;
- }
- }
-
- const r = queue.pop();
- if (i < queue.length) queue[i] = r;
- }
-}
-
-// filter land cells
-function isLand(i) {
- return pack.cells.h[i] >= 20;
-}
-
-// filter water cells
-function isWater(i) {
- return pack.cells.h[i] < 20;
-}
-
-// findAll d3.quandtree search from https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
-void (function addFindAll() {
- const Quad = function (node, x0, y0, x1, y1) {
- this.node = node;
- this.x0 = x0;
- this.y0 = y0;
- this.x1 = x1;
- this.y1 = y1;
- };
-
- const tree_filter = function (x, y, radius) {
- const t = {x, y, x0: this._x0, y0: this._y0, x3: this._x1, y3: this._y1, quads: [], node: this._root};
- if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
- radiusSearchInit(t, radius);
-
- var i = 0;
- while ((t.q = t.quads.pop())) {
- i++;
-
- // Stop searching if this quadrant can’t contain a closer node.
- if (
- !(t.node = t.q.node) ||
- (t.x1 = t.q.x0) > t.x3 ||
- (t.y1 = t.q.y0) > t.y3 ||
- (t.x2 = t.q.x1) < t.x0 ||
- (t.y2 = t.q.y1) < t.y0
- )
- continue;
-
- // Bisect the current quadrant.
- if (t.node.length) {
- t.node.explored = true;
- var xm = (t.x1 + t.x2) / 2,
- ym = (t.y1 + t.y2) / 2;
-
- t.quads.push(
- new Quad(t.node[3], xm, ym, t.x2, t.y2),
- new Quad(t.node[2], t.x1, ym, xm, t.y2),
- new Quad(t.node[1], xm, t.y1, t.x2, ym),
- new Quad(t.node[0], t.x1, t.y1, xm, ym)
- );
-
- // Visit the closest quadrant first.
- if ((t.i = ((y >= ym) << 1) | (x >= xm))) {
- t.q = t.quads[t.quads.length - 1];
- t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
- t.quads[t.quads.length - 1 - t.i] = t.q;
- }
- }
-
- // Visit this point. (Visiting coincident points isn’t necessary!)
- else {
- var dx = x - +this._x.call(null, t.node.data),
- dy = y - +this._y.call(null, t.node.data),
- d2 = dx * dx + dy * dy;
- radiusSearchVisit(t, d2);
- }
- }
- return t.result;
- };
- d3.quadtree.prototype.findAll = tree_filter;
-
- var radiusSearchInit = function (t, radius) {
- t.result = [];
- (t.x0 = t.x - radius), (t.y0 = t.y - radius);
- (t.x3 = t.x + radius), (t.y3 = t.y + radius);
- t.radius = radius * radius;
- };
-
- var radiusSearchVisit = function (t, d2) {
- t.node.data.scanned = true;
- if (d2 < t.radius) {
- do {
- t.result.push(t.node.data);
- t.node.data.selected = true;
- } while ((t.node = t.node.next));
- }
- };
-})();
-
-// draw raster heightmap preview (not used in main generation)
-function drawHeights({heights, width, height, scheme, renderOcean}) {
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d");
- const imageData = ctx.createImageData(width, height);
-
- const getHeight = height => (height < 20 ? (renderOcean ? height : 0) : height);
-
- for (let i = 0; i < heights.length; i++) {
- const color = scheme(1 - getHeight(heights[i]) / 100);
- const {r, g, b} = d3.color(color);
-
- const n = i * 4;
- imageData.data[n] = r;
- imageData.data[n + 1] = g;
- imageData.data[n + 2] = b;
- imageData.data[n + 3] = 255;
- }
-
- ctx.putImageData(imageData, 0, 0);
- return canvas.toDataURL("image/png");
-}
diff --git a/public/utils/languageUtils.js b/public/utils/languageUtils.js
deleted file mode 100644
index 9caa1b6f..00000000
--- a/public/utils/languageUtils.js
+++ /dev/null
@@ -1,174 +0,0 @@
-"use strict";
-
-// chars that serve as vowels
-const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`;
-function vowel(c) {
- return VOWELS.includes(c);
-}
-
-// remove vowels from the end of the string
-function trimVowels(string, minLength = 3) {
- while (string.length > minLength && vowel(last(string))) {
- string = string.slice(0, -1);
- }
- return string;
-}
-
-const adjectivizationRules = [
- {name: "guo", probability: 1, condition: new RegExp(" Guo$"), action: noun => noun.slice(0, -4)},
- {
- name: "orszag",
- probability: 1,
- condition: new RegExp("orszag$"),
- action: noun => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6))
- },
- {
- name: "stan",
- probability: 1,
- condition: new RegExp("stan$"),
- action: noun => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4)))
- },
- {
- name: "land",
- probability: 1,
- condition: new RegExp("land$"),
- action: noun => {
- if (noun.length > 9) return noun.slice(0, -4);
- const root = trimVowels(noun.slice(0, -4), 0);
- if (root.length < 3) return noun + "ic";
- if (root.length < 4) return root + "lish";
- return root + "ish";
- }
- },
- {
- name: "que",
- probability: 1,
- condition: new RegExp("que$"),
- action: noun => noun.replace(/que$/, "can")
- },
- {
- name: "a",
- probability: 1,
- condition: new RegExp("a$"),
- action: noun => noun + "n"
- },
- {
- name: "o",
- probability: 1,
- condition: new RegExp("o$"),
- action: noun => noun.replace(/o$/, "an")
- },
- {
- name: "u",
- probability: 1,
- condition: new RegExp("u$"),
- action: noun => noun + "an"
- },
- {
- name: "i",
- probability: 1,
- condition: new RegExp("i$"),
- action: noun => noun + "an"
- },
- {
- name: "e",
- probability: 1,
- condition: new RegExp("e$"),
- action: noun => noun + "an"
- },
- {
- name: "ay",
- probability: 1,
- condition: new RegExp("ay$"),
- action: noun => noun + "an"
- },
- {
- name: "os",
- probability: 1,
- condition: new RegExp("os$"),
- action: noun => {
- const root = trimVowels(noun.slice(0, -2), 0);
- if (root.length < 4) return noun.slice(0, -1);
- return root + "ian";
- }
- },
- {
- name: "es",
- probability: 1,
- condition: new RegExp("es$"),
- action: noun => {
- const root = trimVowels(noun.slice(0, -2), 0);
- if (root.length > 7) return noun.slice(0, -1);
- return root + "ian";
- }
- },
- {
- name: "l",
- probability: 0.8,
- condition: new RegExp("l$"),
- action: noun => noun + "ese"
- },
- {
- name: "n",
- probability: 0.8,
- condition: new RegExp("n$"),
- action: noun => noun + "ese"
- },
- {
- name: "ad",
- probability: 0.8,
- condition: new RegExp("ad$"),
- action: noun => noun + "ian"
- },
- {
- name: "an",
- probability: 0.8,
- condition: new RegExp("an$"),
- action: noun => noun + "ian"
- },
- {
- name: "ish",
- probability: 0.25,
- condition: new RegExp("^[a-zA-Z]{6}$"),
- action: noun => trimVowels(noun.slice(0, -1)) + "ish"
- },
- {
- name: "an",
- probability: 0.5,
- condition: new RegExp("^[a-zA-Z]{0,7}$"),
- action: noun => trimVowels(noun) + "an"
- }
-];
-
-// get adjective form from noun
-function getAdjective(noun) {
- for (const rule of adjectivizationRules) {
- if (P(rule.probability) && rule.condition.test(noun)) {
- return rule.action(noun);
- }
- }
- return noun; // no rule applied, return noun as is
-}
-
-// get ordinal from integer: 1 => 1st
-const nth = n => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th");
-
-// get two-letters code (abbreviation) from string
-function abbreviate(name, restricted = []) {
- const parsed = name.replace("Old ", "O ").replace(/[()]/g, ""); // remove Old prefix and parentheses
- const words = parsed.split(" ");
- const letters = words.join("");
-
- let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2);
- for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) {
- code = letters[0] + letters[i].toUpperCase();
- }
- return code;
-}
-
-// conjunct array: [A,B,C] => "A, B and C"
-function list(array) {
- if (!Intl.ListFormat) return array.join(", ");
- const conjunction = new Intl.ListFormat(window.lang || "en", {style: "long", type: "conjunction"});
- return conjunction.format(array);
-}
diff --git a/public/utils/nodeUtils.js b/public/utils/nodeUtils.js
deleted file mode 100644
index 0010f3d8..00000000
--- a/public/utils/nodeUtils.js
+++ /dev/null
@@ -1,30 +0,0 @@
-"use strict";
-// FMG utils related to nodes
-
-// remove parent element (usually if child is clicked)
-function removeParent() {
- this.parentNode.parentNode.removeChild(this.parentNode);
-}
-
-// polyfill for composedPath
-function getComposedPath(node) {
- let parent;
- if (node.parentNode) parent = node.parentNode;
- else if (node.host) parent = node.host;
- else if (node.defaultView) parent = node.defaultView;
- if (parent !== undefined) return [node].concat(getComposedPath(parent));
- return [node];
-}
-
-// get next unused id
-function getNextId(core, i = 1) {
- while (document.getElementById(core + i)) i++;
- return core + i;
-}
-
-function getAbsolutePath(href) {
- if (!href) return "";
- const link = document.createElement("a");
- link.href = href;
- return link.href;
-}
diff --git a/public/utils/numberUtils.js b/public/utils/numberUtils.js
deleted file mode 100644
index ada7c284..00000000
--- a/public/utils/numberUtils.js
+++ /dev/null
@@ -1,26 +0,0 @@
-"use strict";
-// FMG utils related to numbers
-
-// round value to d decimals
-function rn(v, d = 0) {
- const m = Math.pow(10, d);
- return Math.round(v * m) / m;
-}
-
-function minmax(value, min, max) {
- return Math.min(Math.max(value, min), max);
-}
-
-// return value in range [0, 100]
-function lim(v) {
- return minmax(v, 0, 100);
-}
-
-// normalization function
-function normalize(val, min, max) {
- return minmax((val - min) / (max - min), 0, 1);
-}
-
-function lerp(a, b, t) {
- return a + (b - a) * t;
-}
diff --git a/public/utils/pathUtils.js b/public/utils/pathUtils.js
deleted file mode 100644
index deafd678..00000000
--- a/public/utils/pathUtils.js
+++ /dev/null
@@ -1,235 +0,0 @@
-"use strict";
-
-// get continuous paths (isolines) for all cells at once based on getType(cellId) comparison
-function getIsolines(graph, getType, options = {polygons: false, fill: false, halo: false, waterGap: false}) {
- const {cells, vertices} = graph;
- const isolines = {};
-
- const checkedCells = new Uint8Array(cells.i.length);
- const addToChecked = cellId => (checkedCells[cellId] = 1);
- const isChecked = cellId => checkedCells[cellId] === 1;
-
- for (const cellId of cells.i) {
- if (isChecked(cellId) || !getType(cellId)) continue;
- addToChecked(cellId);
-
- const type = getType(cellId);
- const ofSameType = cellId => getType(cellId) === type;
- const ofDifferentType = cellId => getType(cellId) !== type;
-
- const onborderCell = cells.c[cellId].find(ofDifferentType);
- if (onborderCell === undefined) continue;
-
- // check if inner lake. Note there is no shoreline for grid features
- const feature = graph.features[cells.f[onborderCell]];
- if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue;
-
- const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
- if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
-
- const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
- if (vertexChain.length < 3) continue;
-
- addIsoline(type, vertices, vertexChain);
- }
-
- return isolines;
-
- function addIsoline(type, vertices, vertexChain) {
- if (!isolines[type]) isolines[type] = {};
-
- if (options.polygons) {
- if (!isolines[type].polygons) isolines[type].polygons = [];
- isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId]));
- }
-
- if (options.fill) {
- if (!isolines[type].fill) isolines[type].fill = "";
- isolines[type].fill += getFillPath(vertices, vertexChain);
- }
-
- if (options.waterGap) {
- if (!isolines[type].waterGap) isolines[type].waterGap = "";
- const isLandVertex = vertexId => vertices.c[vertexId].every(i => cells.h[i] >= 20);
- isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex);
- }
-
- if (options.halo) {
- if (!isolines[type].halo) isolines[type].halo = "";
- const isBorderVertex = vertexId => vertices.c[vertexId].some(i => cells.b[i]);
- isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex);
- }
- }
-}
-
-function getFillPath(vertices, vertexChain) {
- const points = vertexChain.map(vertexId => vertices.p[vertexId]);
- const firstPoint = points.shift();
- return `M${firstPoint} L${points.join(" ")} Z`;
-}
-
-function getBorderPath(vertices, vertexChain, discontinue) {
- let discontinued = true;
- let lastOperation = "";
- const path = vertexChain.map(vertexId => {
- if (discontinue(vertexId)) {
- discontinued = true;
- return "";
- }
-
- const operation = discontinued ? "M" : "L";
- discontinued = false;
- lastOperation = operation;
-
- const command = operation === "L" && operation === lastOperation ? "" : operation;
- return ` ${command}${vertices.p[vertexId]}`;
- });
-
- return path.join("").trim();
-}
-
-// get single path for an non-continuous array of cells
-function getVertexPath(cellsArray) {
- const {cells, vertices} = pack;
-
- const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true]));
- const ofSameType = cellId => cellsObj[cellId];
- const ofDifferentType = cellId => !cellsObj[cellId];
-
- const checkedCells = new Uint8Array(cells.c.length);
- const addToChecked = cellId => (checkedCells[cellId] = 1);
- const isChecked = cellId => checkedCells[cellId] === 1;
-
- let path = "";
-
- for (const cellId of cellsArray) {
- if (isChecked(cellId)) continue;
-
- const onborderCell = cells.c[cellId].find(ofDifferentType);
- if (onborderCell === undefined) continue;
-
- const feature = pack.features[cells.f[onborderCell]];
- if (feature.type === "lake" && feature.shoreline) {
- if (feature.shoreline.every(ofSameType)) continue; // inner lake
- }
-
- const startingVertex = cells.v[cellId].find(v => vertices.c[v].some(ofDifferentType));
- if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
-
- const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
- if (vertexChain.length < 3) continue;
-
- path += getFillPath(vertices, vertexChain);
- }
-
- return path;
-}
-
-function getPolesOfInaccessibility(graph, getType) {
- const isolines = getIsolines(graph, getType, {polygons: true});
-
- const poles = Object.entries(isolines).map(([id, isoline]) => {
- const multiPolygon = isoline.polygons.sort((a, b) => b.length - a.length);
- const [x, y] = polylabel(multiPolygon, 20);
- return [id, [rn(x), rn(y)]];
- });
-
- return Object.fromEntries(poles);
-}
-
-function connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing}) {
- const MAX_ITERATIONS = vertices.c.length;
- const chain = []; // vertices chain to form a path
-
- let next = startingVertex;
- for (let i = 0; i === 0 || next !== startingVertex; i++) {
- const previous = chain.at(-1);
- const current = next;
- chain.push(current);
-
- const neibCells = vertices.c[current];
- if (addToChecked) neibCells.filter(ofSameType).forEach(addToChecked);
-
- const [c1, c2, c3] = neibCells.map(ofSameType);
- const [v1, v2, v3] = vertices.v[current];
-
- if (v1 !== previous && c1 !== c2) next = v1;
- else if (v2 !== previous && c2 !== c3) next = v2;
- else if (v3 !== previous && c1 !== c3) next = v3;
-
- if (next >= vertices.c.length) {
- ERROR && console.error("ConnectVertices: next vertex is out of bounds");
- break;
- }
-
- if (next === current) {
- ERROR && console.error("ConnectVertices: next vertex is not found");
- break;
- }
-
- if (i === MAX_ITERATIONS) {
- ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS);
- break;
- }
- }
-
- if (closeRing) chain.push(startingVertex);
- return chain;
-}
-
-/**
- * Finds the shortest path between two cells using a cost-based pathfinding algorithm.
- * @param {number} start - The ID of the starting cell.
- * @param {(id: number) => boolean} isExit - A function that returns true if the cell is the exit cell.
- * @param {(current: number, next: number) => number} getCost - A function that returns the path cost from current cell to the next cell. Must return `Infinity` for impassable connections.
- * @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same.
- */
-function findPath(start, isExit, getCost) {
- if (isExit(start)) return null;
-
- const from = [];
- const cost = [];
- const queue = new FlatQueue();
- queue.push(start, 0);
-
- while (queue.length) {
- const currentCost = queue.peekValue();
- const current = queue.pop();
-
- for (const next of pack.cells.c[current]) {
- if (isExit(next)) {
- from[next] = current;
- return restorePath(next, start, from);
- }
-
- const nextCost = getCost(current, next);
- if (nextCost === Infinity) continue; // impassable cell
- const totalCost = currentCost + nextCost;
-
- if (totalCost >= cost[next]) continue; // has cheaper path
- from[next] = current;
- cost[next] = totalCost;
- queue.push(next, totalCost);
- }
- }
-
- return null;
-}
-
-// supplementary function for findPath
-function restorePath(exit, start, from) {
- const pathCells = [];
-
- let current = exit;
- let prev = exit;
-
- while (current !== start) {
- pathCells.push(current);
- prev = from[current];
- current = prev;
- }
-
- pathCells.push(current);
-
- return pathCells.reverse();
-}
diff --git a/public/utils/polyfills.js b/public/utils/polyfills.js
deleted file mode 100644
index ffc10f74..00000000
--- a/public/utils/polyfills.js
+++ /dev/null
@@ -1,41 +0,0 @@
-"use strict";
-
-// replaceAll
-if (String.prototype.replaceAll === undefined) {
- String.prototype.replaceAll = function (str, newStr) {
- if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str, newStr);
- return this.replace(new RegExp(str, "g"), newStr);
- };
-}
-
-// flat
-if (Array.prototype.flat === undefined) {
- Array.prototype.flat = function () {
- return this.reduce((acc, val) => (Array.isArray(val) ? acc.concat(val.flat()) : acc.concat(val)), []);
- };
-}
-
-// at
-if (Array.prototype.at === undefined) {
- Array.prototype.at = function (index) {
- if (index < 0) index += this.length;
- if (index < 0 || index >= this.length) return undefined;
- return this[index];
- };
-}
-
-// readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10
-if (ReadableStream.prototype[Symbol.asyncIterator] === undefined) {
- ReadableStream.prototype[Symbol.asyncIterator] = async function* () {
- const reader = this.getReader();
- try {
- while (true) {
- const {done, value} = await reader.read();
- if (done) return;
- yield value;
- }
- } finally {
- reader.releaseLock();
- }
- };
-}
diff --git a/public/utils/probabilityUtils.js b/public/utils/probabilityUtils.js
deleted file mode 100644
index db74c85f..00000000
--- a/public/utils/probabilityUtils.js
+++ /dev/null
@@ -1,87 +0,0 @@
-"use strict";
-// FMG utils related to randomness
-
-// random number in a range
-function rand(min, max) {
- if (min === undefined && max === undefined) return Math.random();
- if (max === undefined) {
- max = min;
- min = 0;
- }
- return Math.floor(Math.random() * (max - min + 1)) + min;
-}
-
-// probability shorthand
-function P(probability) {
- if (probability >= 1) return true;
- if (probability <= 0) return false;
- return Math.random() < probability;
-}
-
-function each(n) {
- return i => i % n === 0;
-}
-
-/* Random Gaussian number generator
- * @param {number} expected - expected value
- * @param {number} deviation - standard deviation
- * @param {number} min - minimum value
- * @param {number} max - maximum value
- * @param {number} round - round value to n decimals
- * @return {number} random number
- */
-function gauss(expected = 100, deviation = 30, min = 0, max = 300, round = 0) {
- return rn(minmax(d3.randomNormal(expected, deviation)(), min, max), round);
-}
-
-// probability shorthand for floats
-function Pint(float) {
- return ~~float + +P(float % 1);
-}
-
-// return random value from the array
-function ra(array) {
- return array[Math.floor(Math.random() * array.length)];
-}
-
-// return random value from weighted array {"key1":weight1, "key2":weight2}
-function rw(object) {
- const array = [];
- for (const key in object) {
- for (let i = 0; i < object[key]; i++) {
- array.push(key);
- }
- }
- return array[Math.floor(Math.random() * array.length)];
-}
-
-// return a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min)
-function biased(min, max, ex) {
- return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
-}
-
-// get number from string in format "1-3" or "2" or "0.5"
-function getNumberInRange(r) {
- if (typeof r !== "string") {
- ERROR && console.error("Range value should be a string", r);
- return 0;
- }
- if (!isNaN(+r)) return ~~r + +P(r - ~~r);
- const sign = r[0] === "-" ? -1 : 1;
- if (isNaN(+r[0])) r = r.slice(1);
- const range = r.includes("-") ? r.split("-") : null;
- if (!range) {
- ERROR && console.error("Cannot parse the number. Check the format", r);
- return 0;
- }
- const count = rand(range[0] * sign, +range[1]);
- if (isNaN(count) || count < 0) {
- ERROR && console.error("Cannot parse number. Check the format", r);
- return 0;
- }
- return count;
-}
-
-function generateSeed() {
- return String(Math.floor(Math.random() * 1e9));
-}
diff --git a/public/utils/shorthands.js b/public/utils/shorthands.js
deleted file mode 100644
index 36e9d09a..00000000
--- a/public/utils/shorthands.js
+++ /dev/null
@@ -1,11 +0,0 @@
-const byId = document.getElementById.bind(document);
-
-Node.prototype.on = function (name, fn, options) {
- this.addEventListener(name, fn, options);
- return this;
-};
-
-Node.prototype.off = function (name, fn) {
- this.removeEventListener(name, fn);
- return this;
-};
diff --git a/public/utils/stringUtils.js b/public/utils/stringUtils.js
deleted file mode 100644
index a3182f14..00000000
--- a/public/utils/stringUtils.js
+++ /dev/null
@@ -1,81 +0,0 @@
-"use strict";
-// FMG utils related to strings
-
-// round numbers in string to d decimals
-function round(s, d = 1) {
- return s.replace(/[\d\.-][\d\.e-]*/g, function (n) {
- return rn(n, d);
- });
-}
-
-// return string with 1st char capitalized
-function capitalize(string) {
- return string.charAt(0).toUpperCase() + string.slice(1);
-}
-
-// split string into 2 almost equal parts not breaking words
-function splitInTwo(str) {
- const half = str.length / 2;
- const ar = str.split(" ");
- if (ar.length < 2) return ar; // only one word
- let first = "",
- last = "",
- middle = "",
- rest = "";
-
- ar.forEach((w, d) => {
- if (d + 1 !== ar.length) w += " ";
- rest += w;
- if (!first || rest.length < half) first += w;
- else if (!middle) middle = w;
- else last += w;
- });
-
- if (!last) return [first, middle];
- if (first.length < last.length) return [first + middle, last];
- return [first, middle + last];
-}
-
-// transform string to array [translateX,translateY,rotateDeg,rotateX,rotateY,scale]
-function parseTransform(string) {
- if (!string) return [0, 0, 0, 0, 0, 1];
-
- const a = string
- .replace(/[a-z()]/g, "")
- .replace(/[ ]/g, ",")
- .split(",");
- return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1];
-}
-
-// check if string is a valid for JSON parse
-JSON.isValid = str => {
- try {
- JSON.parse(str);
- return true;
- } catch (e) {
- return false;
- }
-};
-
-JSON.safeParse = str => {
- try {
- return JSON.parse(str);
- } catch (e) {
- return null;
- }
-};
-
-function sanitizeId(string) {
- if (!string) throw new Error("No string provided");
-
- let sanitized = string
- .toLowerCase()
- .trim()
- .replace(/[^a-z0-9-_]/g, "") // no invalid characters
- .replace(/\s+/g, "-"); // replace spaces with hyphens
-
- // remove leading numbers
- if (sanitized.match(/^\d/)) sanitized = "_" + sanitized;
-
- return sanitized;
-}
diff --git a/public/utils/unitUtils.js b/public/utils/unitUtils.js
deleted file mode 100644
index d940e349..00000000
--- a/public/utils/unitUtils.js
+++ /dev/null
@@ -1,37 +0,0 @@
-"use strict";
-// FMG utils related to units
-
-// conver temperature from °C to other scales
-const temperatureConversionMap = {
- "°C": temp => rn(temp) + "°C",
- "°F": temp => rn((temp * 9) / 5 + 32) + "°F",
- K: temp => rn(temp + 273.15) + "K",
- "°R": temp => rn(((temp + 273.15) * 9) / 5) + "°R",
- "°De": temp => rn(((100 - temp) * 3) / 2) + "°De",
- "°N": temp => rn((temp * 33) / 100) + "°N",
- "°Ré": temp => rn((temp * 4) / 5) + "°Ré",
- "°Rø": temp => rn((temp * 21) / 40 + 7.5) + "°Rø"
-};
-
-function convertTemperature(temp, scale = temperatureScale.value || "°C") {
- return temperatureConversionMap[scale](temp);
-}
-
-// corvent number to short string with SI postfix
-function si(n) {
- if (n >= 1e9) return rn(n / 1e9, 1) + "B";
- if (n >= 1e8) return rn(n / 1e6) + "M";
- if (n >= 1e6) return rn(n / 1e6, 1) + "M";
- if (n >= 1e4) return rn(n / 1e3) + "K";
- if (n >= 1e3) return rn(n / 1e3, 1) + "K";
- return rn(n);
-}
-
-// getInteger number from user input data
-function getInteger(value) {
- const metric = value.slice(-1);
- if (metric === "K") return parseInt(value.slice(0, -1) * 1e3);
- if (metric === "M") return parseInt(value.slice(0, -1) * 1e6);
- if (metric === "B") return parseInt(value.slice(0, -1) * 1e9);
- return parseInt(value);
-}
diff --git a/src/index.html b/src/index.html
index b6dfe265..b5fbf8e6 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8464,54 +8464,40 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts
new file mode 100644
index 00000000..5772cb14
--- /dev/null
+++ b/src/utils/arrayUtils.ts
@@ -0,0 +1,107 @@
+/**
+ * Get the last element of an array
+ * @param {Array} array - The array to get the last element from
+ * @returns The last element of the array
+ */
+export const last = (array: T[]): T => {
+ return array[array.length - 1];
+}
+
+/**
+ * Get unique elements from an array
+ * @param {Array} array - The array to get unique elements from
+ * @returns An array with unique elements
+ */
+export const unique = (array: T[]): T[] => {
+ return [...new Set(array)];
+}
+
+/**
+ * Deep copy an object or array
+ * @param {Object|Array} obj - The object or array to deep copy
+ * @returns A deep copy of the object or array
+ */
+export const deepCopy = (obj: T): T => {
+ const id = (x: T): T => x;
+ const dcTArray = (a: T[]): T[] => a.map(id);
+ const dcObject = (x: object): object => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)]));
+ const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x);
+ // don't map keys, probably this is what we would expect
+ const dcMapCore = (m: Map): [any, any][] => [...m.entries()].map(([k, v]) => [k, dcAny(v)]);
+
+ const cf: Map any> = new Map any>([
+ [Int8Array, dcTArray],
+ [Uint8Array, dcTArray],
+ [Uint8ClampedArray, dcTArray],
+ [Int16Array, dcTArray],
+ [Uint16Array, dcTArray],
+ [Int32Array, dcTArray],
+ [Uint32Array, dcTArray],
+ [Float32Array, dcTArray],
+ [Float64Array, dcTArray],
+ [BigInt64Array, dcTArray],
+ [BigUint64Array, dcTArray],
+ [Map, m => new Map(dcMapCore(m))],
+ [WeakMap, m => new WeakMap(dcMapCore(m))],
+ [Array, a => a.map(dcAny)],
+ [Set, s => [...s.values()].map(dcAny)],
+ [Date, d => new Date(d.getTime())],
+ [Object, dcObject]
+ // ... extend here to implement their custom deep copy
+ ]);
+
+ return dcAny(obj);
+}
+
+/**
+ * Get the appropriate typed array constructor based on the maximum value
+ * @param {number} maxValue - The maximum value that will be stored in the array
+ * @returns The typed array constructor
+ */
+export const getTypedArray = (maxValue: number) => {
+ console.assert(
+ Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX,
+ `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}`
+ );
+
+ if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT8_MAX) return Uint8Array;
+ if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array;
+ if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array;
+ return Uint32Array;
+}
+
+/**
+ * Create a typed array based on the maximum value and length or from an existing array
+ * @param {Object} options - The options for creating the typed array
+ * @param {number} options.maxValue - The maximum value that will be stored in the array
+ * @param {number} options.length - The length of the typed array to create
+ * @param {Array} [options.from] - An optional array to create the typed array from
+ * @returns The created typed array
+ */
+export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike}) => {
+ const typedArray = getTypedArray(maxValue);
+ if (!from) return new typedArray(length);
+ return typedArray.from(from);
+}
+
+// typed arrays max values
+export const TYPED_ARRAY_MAX_VALUES = {
+ INT8_MAX: 127,
+ UINT8_MAX: 255,
+ UINT16_MAX: 65535,
+ UINT32_MAX: 4294967295
+};
+
+declare global {
+ interface Window {
+ last: typeof last;
+ unique: typeof unique;
+ deepCopy: typeof deepCopy;
+ getTypedArray: typeof getTypedArray;
+ createTypedArray: typeof createTypedArray;
+ INT8_MAX: number;
+ UINT8_MAX: number;
+ UINT16_MAX: number;
+ UINT32_MAX: number;
+ }
+}
diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts
new file mode 100644
index 00000000..047d6eae
--- /dev/null
+++ b/src/utils/colorUtils.ts
@@ -0,0 +1,79 @@
+import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3";
+
+/**
+ * Convert RGB or RGBA color to HEX
+ * @param {string} rgba - The RGB or RGBA color string
+ * @returns {string} - The HEX color string
+ */
+export const toHEX = (rgba: string): string => {
+ if (rgba.charAt(0) === "#") return rgba;
+
+ const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
+ return matches && matches.length === 4
+ ? "#" +
+ ("0" + parseInt(matches[1], 10).toString(16)).slice(-2) +
+ ("0" + parseInt(matches[2], 10).toString(16)).slice(-2) +
+ ("0" + parseInt(matches[3], 10).toString(16)).slice(-2)
+ : "";
+}
+
+/** Predefined set of 12 distinct colors */
+export const C_12 = [
+ "#dababf",
+ "#fb8072",
+ "#80b1d3",
+ "#fdb462",
+ "#b3de69",
+ "#fccde5",
+ "#c6b9c1",
+ "#bc80bd",
+ "#ccebc5",
+ "#ffed6f",
+ "#8dd3c7",
+ "#eb8de7"
+];
+
+/**
+ * Get an array of distinct colors
+ * @param {number} count - The count of colors to generate
+ * @returns {string[]} - The array of HEX color strings
+*/
+export const getColors = (count: number): string[] => {
+ const scaleRainbow = scaleSequential(interpolateRainbow);
+ const colors = shuffle(
+ range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex()))
+ );
+ return colors.filter((c): c is string => typeof c === "string");
+}
+
+/**
+ * Get a random color in HEX format
+ * @returns {string} - The HEX color string
+ */
+export const getRandomColor = (): string => {
+ const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor;
+ return colorFromRainbow.formatHex();
+}
+
+/**
+ * Get a mixed color by blending a given color with a random color
+ * @param {string} color - The base color in HEX format
+ * @param {number} mix - The mix ratio (0 to 1)
+ * @param {number} bright - The brightness adjustment
+ * @returns {string} - The mixed HEX color string
+ */
+export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): string => {
+ const c = colorToMix && colorToMix[0] === "#" ? colorToMix : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one
+ const mixedColor: RGBColor = color(interpolate(c, getRandomColor())(mix)) as RGBColor;
+ return mixedColor.brighter(bright).formatHex();
+}
+
+declare global {
+ interface Window {
+ toHEX: typeof toHEX;
+ getColors: typeof getColors;
+ getRandomColor: typeof getRandomColor;
+ getMixedColor: typeof getMixedColor;
+ C_12: typeof C_12;
+ }
+}
diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts
new file mode 100644
index 00000000..d5f2fc9a
--- /dev/null
+++ b/src/utils/commonUtils.ts
@@ -0,0 +1,320 @@
+import { distanceSquared } from "./functionUtils";
+import { rand } from "./probabilityUtils";
+import { rn } from "./numberUtils";
+import { last } from "./arrayUtils";
+
+/**
+ * Clip polygon points to graph boundaries
+ * @param points - Array of points [[x1, y1], [x2, y2], ...]
+ * @param graphWidth - Width of the graph
+ * @param graphHeight - Height of the graph
+ * @param secure - Secure clipping to avoid edge artifacts
+ * @returns Clipped polygon points
+ */
+export const clipPoly = (points: [number, number][], graphWidth: number, graphHeight: number, secure: number = 0) => {
+ if (points.length < 2) return points;
+ if (points.some(point => point === undefined)) {
+ window.ERROR && console.error("Undefined point in clipPoly", points);
+ return points;
+ }
+
+ return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure);
+}
+
+/**
+ * Get segment of any point on polyline
+ * @param points - Array of points defining the polyline
+ * @param point - The point to find the segment for
+ * @param step - Step size for segment search (default is 10)
+ * @returns The segment ID (1-indexed)
+ */
+export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => {
+ if (points.length === 2) return 1;
+
+ let minSegment = 1;
+ let minDist = Infinity;
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+
+ const length = Math.sqrt(distanceSquared(p1, p2));
+ const segments = Math.ceil(length / step);
+ const dx = (p2[0] - p1[0]) / segments;
+ const dy = (p2[1] - p1[1]) / segments;
+
+ for (let s = 0; s < segments; s++) {
+ const x = p1[0] + s * dx;
+ const y = p1[1] + s * dy;
+ const dist = distanceSquared(point, [x, y]);
+
+ if (dist >= minDist) continue;
+ minDist = dist;
+ minSegment = i + 1;
+ }
+ }
+
+ return minSegment;
+}
+
+/**
+ * Creates a debounced function that delays invoking func until after ms milliseconds have elapsed
+ * @param func - The function to debounce
+ * @param ms - The number of milliseconds to delay
+ * @returns The debounced function
+ */
+export const debounce = 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);
+ };
+}
+
+/**
+ * Creates a throttled function that only invokes func at most once every ms milliseconds
+ * @param func - The function to throttle
+ * @param ms - The number of milliseconds to throttle invocations to
+ * @returns The throttled function
+ */
+export const throttle = any>(func: T, ms: number) => {
+ let isThrottled = false;
+ let savedArgs: any[] | null = null;
+ let savedThis: any = null;
+
+ function wrapper(this: any, ...args: Parameters) {
+ if (isThrottled) {
+ savedArgs = args;
+ savedThis = this;
+ return;
+ }
+
+ func.apply(this, args);
+ isThrottled = true;
+
+ setTimeout(function () {
+ isThrottled = false;
+ if (savedArgs) {
+ wrapper.apply(savedThis, savedArgs as Parameters);
+ savedArgs = savedThis = null;
+ }
+ }, ms);
+ }
+
+ return wrapper;
+}
+
+/**
+ * Parse error to get the readable string in Chrome and Firefox
+ * @param error - The error object to parse
+ * @returns Formatted error string with HTML formatting
+ */
+export const parseError = (error: Error): string => {
+ const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
+ const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack || "";
+ const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi;
+ const errorNoURL = errorString.replace(regex, url => "" + 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 => {
+ const xhr = new XMLHttpRequest();
+ xhr.onload = function () {
+ const reader = new FileReader();
+ reader.onloadend = function () {
+ callback(reader.result);
+ };
+ reader.readAsDataURL(xhr.response);
+ };
+ xhr.open("GET", url);
+ xhr.responseType = "blob";
+ xhr.send();
+}
+
+/**
+ * Open URL in a new tab or window
+ * @param url - The URL to open
+ */
+export const openURL = (url: string): void => {
+ window.open(url, "_blank");
+}
+
+/**
+ * Open project wiki-page
+ * @param page - The wiki page name/path to open
+ */
+export const wiki = (page: string): void => {
+ window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank");
+}
+
+/**
+ * Wrap URL into html a element
+ * @param URL - The URL for the link
+ * @param description - The link text/description
+ * @returns HTML anchor element as a string
+ */
+export const link = (URL: string, description: string): string => {
+ return `${description}`;
+}
+
+/**
+ * Check if Ctrl key (or Cmd on Mac) was pressed during an event
+ * @param event - The keyboard or mouse event
+ * @returns True if Ctrl/Cmd was pressed
+ */
+export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => {
+ // meta key is cmd key on MacOs
+ return event.ctrlKey || event.metaKey;
+}
+
+/**
+ * Generate a random date within a specified range
+ * @param from - Start year (default is 100)
+ * @param to - End year (default is 1000)
+ * @returns Formatted date string
+ */
+export const generateDate = (from: number = 100, to: number = 1000): string => {
+ return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", {
+ year: "numeric",
+ month: "long",
+ day: "numeric"
+ });
+}
+
+/**
+ * Convert x coordinate to longitude
+ * @param x - The x coordinate
+ * @param mapCoordinates - Map coordinates object with lonW and lonT properties
+ * @param graphWidth - Width of the graph
+ * @param decimals - Number of decimal places (default is 2)
+ * @returns Longitude value
+ */
+export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => {
+ return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals);
+}
+
+/**
+ * Convert y coordinate to latitude
+ * @param y - The y coordinate
+ * @param mapCoordinates - Map coordinates object with latN and latT properties
+ * @param graphHeight - Height of the graph
+ * @param decimals - Number of decimal places (default is 2)
+ * @returns Latitude value
+ */
+export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => {
+ return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals);
+}
+
+/**
+ * Convert x,y coordinates to longitude,latitude
+ * @param x - The x coordinate
+ * @param y - The y coordinate
+ * @param mapCoordinates - Map coordinates object
+ * @param graphWidth - Width of the graph
+ * @param graphHeight - Height of the graph
+ * @param decimals - Number of decimal places (default is 2)
+ * @returns Array with [longitude, latitude]
+ */
+export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => {
+ return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)];
+}
+
+/**
+ * Prompt options interface
+ */
+export interface PromptOptions {
+ default: number | string;
+ step?: number;
+ min?: number;
+ max?: number;
+ required?: boolean;
+}
+
+/**
+ * Initialize custom prompt function (prompt does not work in Electron)
+ * This should be called once when the DOM is ready
+ */
+export const initializePrompt = (): void => {
+ const prompt = document.getElementById("prompt");
+ if (!prompt) return;
+
+ const form = prompt.querySelector("#promptForm");
+ if (!form) return;
+
+ const defaultText = "Please provide an input";
+ const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true};
+
+ (window as any).prompt = function (promptText: string = defaultText, options: PromptOptions = defaultOptions, callback?: (value: number | string) => void) {
+ if (options.default === undefined)
+ return window.ERROR && console.error("Prompt: options object does not have default value defined");
+
+ const input = prompt.querySelector("#promptInput") as HTMLInputElement;
+ const promptTextElement = prompt.querySelector("#promptText") as HTMLElement;
+
+ if (!input || !promptTextElement) return;
+
+ promptTextElement.innerHTML = promptText;
+
+ const type = typeof options.default === "number" ? "number" : "text";
+ input.type = type;
+
+ if (options.step !== undefined) input.step = options.step.toString();
+ if (options.min !== undefined) input.min = options.min.toString();
+ if (options.max !== undefined) input.max = options.max.toString();
+
+ input.required = options.required === false ? false : true;
+ input.placeholder = "type a " + type;
+ input.value = options.default.toString();
+ input.style.width = promptText.length > 10 ? "100%" : "auto";
+ prompt.style.display = "block";
+
+ form.addEventListener(
+ "submit",
+ (event: Event) => {
+ event.preventDefault();
+ prompt.style.display = "none";
+ const v = type === "number" ? +input.value : input.value;
+ if (callback) callback(v);
+ },
+ {once: true}
+ );
+ };
+
+ const cancel = prompt.querySelector("#promptCancel");
+ if (cancel) {
+ cancel.addEventListener("click", () => {
+ prompt.style.display = "none";
+ });
+ }
+}
+
+declare global {
+ interface Window {
+ ERROR: boolean;
+ polygonclip: any;
+
+ clipPoly: typeof clipPoly;
+ getSegmentId: typeof getSegmentId;
+ debounce: typeof debounce;
+ throttle: typeof throttle;
+ parseError: typeof parseError;
+ getBase64: typeof getBase64;
+ openURL: typeof openURL;
+ wiki: typeof wiki;
+ link: typeof link;
+ isCtrlClick: typeof isCtrlClick;
+ generateDate: typeof generateDate;
+ getLongitude: typeof getLongitude;
+ getLatitude: typeof getLatitude;
+ getCoordinates: typeof getCoordinates;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts
new file mode 100644
index 00000000..6b236ebe
--- /dev/null
+++ b/src/utils/debugUtils.ts
@@ -0,0 +1,114 @@
+import {curveBundle, line, max, min} from "d3";
+import { normalize } from "./numberUtils";
+import { getGridPolygon } from "./graphUtils";
+import { C_12 } from "./colorUtils";
+import { round } from "./stringUtils";
+
+/**
+ * Drawing cell values and polygons for debugging purposes
+ * @param {any[]} data - Array of data values corresponding to each cell
+ * @param {any} packedGraph - The packed graph object containing cell positions
+ */
+export const drawCellsValue = (data: any[], packedGraph: any): void => {
+ window.debug.selectAll("text").remove();
+ window.debug
+ .selectAll("text")
+ .data(data)
+ .enter()
+ .append("text")
+ .attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0])
+ .attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1])
+ .text((d: any) => d);
+}
+/**
+ * Drawing polygons colored according to data values for debugging purposes
+ * @param {number[]} data - Array of numerical values corresponding to each cell
+ * @param {any} terrs - The SVG group element where the polygons will be drawn
+ */
+export const drawPolygons = (data: number[], terrs: any, grid: any): void => {
+ const maximum: number = max(data) as number;
+ const minimum: number = min(data) as number;
+ const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme"));
+
+ data = data.map(d => 1 - normalize(d, minimum, maximum));
+ window.debug.selectAll("polygon").remove();
+ window.debug
+ .selectAll("polygon")
+ .data(data)
+ .enter()
+ .append("polygon")
+ .attr("points", (_d: number, i: number) => getGridPolygon(i, grid))
+ .attr("fill", (d: number) => scheme(d))
+ .attr("stroke", (d: number) => scheme(d));
+}
+
+/**
+ * Drawing route connections for debugging purposes
+ * @param {any} pack - The packed graph object containing cell positions and routes
+ */
+export const drawRouteConnections = (packedGraph: any): void => {
+ window.debug.select("#connections").remove();
+ const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8);
+
+ const points = packedGraph.cells.p;
+ const links = packedGraph.cells.routes;
+
+ for (const from in links) {
+ for (const to in links[from]) {
+ const [x1, y1] = points[from];
+ const [x3, y3] = points[to];
+ const [x2, y2] = [(x1 + x3) / 2, (y1 + y3) / 2];
+ const routeId = links[from][to];
+
+ routes
+ .append("line")
+ .attr("x1", x1)
+ .attr("y1", y1)
+ .attr("x2", x2)
+ .attr("y2", y2)
+ .attr("data-id", routeId)
+ .attr("stroke", C_12[routeId % 12]);
+ }
+ }
+}
+
+/**
+ * Drawing a point for debugging purposes
+ * @param {[number, number]} point - The [x, y] coordinates of the point to draw
+ * @param {Object} options - Options for drawing the point
+ * @param {string} options.color - Color of the point
+ * @param {number} options.radius - Radius of the point
+ */
+export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => {
+ window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color);
+}
+
+/**
+ * Drawing a path for debugging purposes
+ * @param {[number, number][]} points - Array of [x, y] coordinates representing the path
+ * @param {Object} options - Options for drawing the path
+ * @param {string} options.color - Color of the path
+ * @param {number} options.width - Stroke width of the path
+ */
+export const drawPath = (points: [number, number][], {color = "red", width = 0.5}): void => {
+ const lineGen = line().curve(curveBundle);
+ window.debug
+ .append("path")
+ .attr("d", round(lineGen(points) as string))
+ .attr("stroke", color)
+ .attr("stroke-width", width)
+ .attr("fill", "none");
+}
+
+declare global {
+ interface Window {
+ debug: any;
+ getColorScheme: (name: string) => (t: number) => string;
+
+ drawCellsValue: typeof drawCellsValue;
+ drawPolygons: typeof drawPolygons;
+ drawRouteConnections: typeof drawRouteConnections;
+ drawPoint: typeof drawPoint;
+ drawPath: typeof drawPath;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/functionUtils.ts b/src/utils/functionUtils.ts
new file mode 100644
index 00000000..5a3d7283
--- /dev/null
+++ b/src/utils/functionUtils.ts
@@ -0,0 +1,64 @@
+/**
+ * Regroup an array of values by multiple keys and reduce each group
+ * @param {Array} values - The array of values to regroup
+ * @param {Function} reduce - The reduce function to apply to each group
+ * @param {...Function} keys - The key functions to group by
+ * @returns {Map} - The regrouped and reduced Map
+ *
+ * @example
+ * const data = [
+ * {category: 'A', type: 'X', value: 10},
+ * {category: 'A', type: 'Y', value: 20},
+ * {category: 'B', type: 'X', value: 30},
+ * {category: 'B', type: 'Y', value: 40},
+ * ];
+ * const result = rollups(
+ * data,
+ * v => v.reduce((sum, d) => sum + d.value, 0),
+ * d => d.category,
+ * d => d.type
+ * );
+ * // result is a Map with structure:
+ * // Map {
+ * // 'A' => Map { 'X' => 10, 'Y' => 20 },
+ * // 'B' => Map { 'X' => 30, 'Y' => 40 }
+ * // }
+ */
+export const rollups = (values: any[], reduce: (values: any[]) => any, ...keys: ((value: any, index: number, array: any[]) => any)[]) => {
+ return nest(values, Array.from, reduce, keys);
+}
+
+const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (values: any[]) => any, keys: ((value: any, index: number, array: any[]) => any)[]) => {
+ return (function regroup(values, i) {
+ if (i >= keys.length) return reduce(values);
+ const groups = new Map();
+ const keyof = keys[i++];
+ let index = -1;
+ for (const value of values) {
+ const key = keyof(value, ++index, values);
+ const group = groups.get(key);
+ if (group) group.push(value);
+ else groups.set(key, [value]);
+ }
+ for (const [key, values] of groups) {
+ groups.set(key, regroup(values, i));
+ }
+ return map(groups);
+ })(values, 0);
+}
+
+/**
+ * Calculate squared distance between two points
+ * @param {[number, number]} p1 - First point [x1, y1]
+ * @param {[number, number]} p2 - Second point [x2, y2]
+ * @returns {number} - Squared distance between p1 and p2
+ */
+export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => {
+ return (x1 - x2) ** 2 + (y1 - y2) ** 2;
+}
+declare global {
+ interface Window {
+ rollups: typeof rollups;
+ dist2: typeof distanceSquared;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts
new file mode 100644
index 00000000..9b756624
--- /dev/null
+++ b/src/utils/graphUtils.ts
@@ -0,0 +1,454 @@
+import Delaunator from "delaunator";
+import Alea from "alea";
+import { color } from "d3";
+import { byId } from "./shorthands";
+import { rn } from "./numberUtils";
+import { createTypedArray } from "./arrayUtils";
+
+/**
+ * Get boundary points on a regular square grid
+ * @param {number} width - The width of the area
+ * @param {number} height - The height of the area
+ * @param {number} spacing - The spacing between points
+ * @returns {Array} - An array of boundary points
+ */
+const getBoundaryPoints = (width: number, height: number, spacing: number) => {
+ const offset = rn(-1 * spacing);
+ const bSpacing = spacing * 2;
+ const w = width - offset * 2;
+ const h = height - offset * 2;
+ const numberX = Math.ceil(w / bSpacing) - 1;
+ const numberY = Math.ceil(h / bSpacing) - 1;
+ const points = [];
+
+ for (let i = 0.5; i < numberX; i++) {
+ let x = Math.ceil((w * i) / numberX + offset);
+ points.push([x, offset], [x, h + offset]);
+ }
+
+ for (let i = 0.5; i < numberY; i++) {
+ let y = Math.ceil((h * i) / numberY + offset);
+ points.push([offset, y], [w + offset, y]);
+ }
+
+ return points;
+}
+
+/**
+ * Get points on a jittered square grid
+ * @param {number} width - The width of the area
+ * @param {number} height - The height of the area
+ * @param {number} spacing - The spacing between points
+ * @returns {Array} - An array of jittered grid points
+ */
+const getJitteredGrid = (width: number, height: number, spacing: number): number[][] => {
+ 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: number[][] = [];
+ for (let y = radius; y < height; y += spacing) {
+ for (let x = radius; x < width; x += spacing) {
+ const xj = Math.min(rn(x + jitter(), 2), width);
+ const yj = Math.min(rn(y + jitter(), 2), height);
+ points.push([xj, yj]);
+ }
+ }
+ return points;
+}
+
+/**
+ * Places points on a jittered grid and calculates spacing and cell counts
+ * @param {number} graphWidth - The width of the graph
+ * @param {number} graphHeight - The height of the graph
+ * @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY
+ */
+const placePoints = (graphWidth: number, graphHeight: number) => {
+ window.TIME && console.time("placePoints");
+ const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0);
+ const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jirrering
+
+ const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
+ const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid
+ const cellsX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing);
+ const cellsY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing);
+ window.TIME && console.timeEnd("placePoints");
+
+ return {spacing, cellsDesired, boundary, points, cellsX, cellsY};
+}
+
+
+/**
+ * Checks if the grid needs to be regenerated based on desired parameters
+ * @param {Object} grid - The current grid object
+ * @param {number} expectedSeed - The expected seed value
+ * @param {number} graphWidth - The width of the graph
+ * @param {number} graphHeight - The height of the graph
+ * @returns {boolean} - True if the grid should be regenerated, false otherwise
+ */
+export const shouldRegenerateGrid = (grid: any, expectedSeed: number, graphWidth: number, graphHeight: number) => {
+ if (expectedSeed && expectedSeed !== grid.seed) return true;
+
+ const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0);
+ if (cellsDesired !== grid.cellsDesired) return true;
+
+ const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2);
+ const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing);
+ const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing);
+
+ return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY;
+}
+
+/**
+ * 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) => {
+ Math.random = Alea(seed); // reset PRNG
+ const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight);
+ const {cells, vertices} = calculateVoronoi(points, boundary);
+ return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed};
+}
+
+/**
+ * Calculates the Voronoi diagram from given points and boundary
+ * @param {Array} points - The array of points for Voronoi calculation
+ * @param {Array} boundary - The boundary points to clip the Voronoi cells
+ * @returns {Object} - An object containing Voronoi cells and vertices
+ */
+export const calculateVoronoi = (points: number[][], boundary: number[][]) => {
+ window.TIME && console.time("calculateDelaunay");
+ const allPoints = points.concat(boundary);
+ const delaunay = Delaunator.from(allPoints);
+ window.TIME && console.timeEnd("calculateDelaunay");
+
+ window.TIME && console.time("calculateVoronoi");
+ const voronoi = new window.Voronoi(delaunay, allPoints, points.length);
+
+ const cells = voronoi.cells;
+ cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i); // array of indexes
+ const vertices = voronoi.vertices;
+ window.TIME && console.timeEnd("calculateVoronoi");
+
+ return {cells, vertices};
+}
+
+/**
+ * Returns a cell index on a regular square grid based on x and y coordinates
+ * @param {number} x - The x coordinate
+ * @param {number} y - The y coordinate
+ * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
+ * @returns {number} - The index of the cell in the grid
+ */
+export const findGridCell = (x: number, y: number, grid: any): number => {
+ return (
+ Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX +
+ Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1))
+ );
+}
+
+/**
+ * return array of cell indexes in radius on a regular square grid
+ * @param {number} x - The x coordinate
+ * @param {number} y - The y coordinate
+ * @param {number} radius - The search radius
+ * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
+ * @returns {Array} - An array of cell indexes within the specified radius
+ */
+export const findGridAll = (x: number, y: number, radius: number, grid: any): number[] => {
+ const c = grid.cells.c;
+ let r = Math.floor(radius / grid.spacing);
+ let found = [findGridCell(x, y, grid)];
+ if (!r || radius === 1) return found;
+ if (r > 0) found = found.concat(c[found[0]]);
+ if (r > 1) {
+ let frontier = c[found[0]];
+ while (r > 1) {
+ let cycle = frontier.slice();
+ frontier = [];
+ cycle.forEach(function (s: number) {
+ c[s].forEach(function (e: number) {
+ if (found.indexOf(e) !== -1) return;
+ found.push(e);
+ frontier.push(e);
+ });
+ });
+ r--;
+ }
+ }
+
+ return found;
+}
+
+/**
+ * Returns the index of the packed cell containing the given x and y coordinates
+ * @param {number} x - The x coordinate
+ * @param {number} y - The y coordinate
+ * @param {number} radius - The search radius (default is Infinity)
+ * @returns {number|undefined} - The index of the found cell or undefined if not found
+ */
+export const findClosestCell = (x: number, y: number, radius = Infinity, packedGraph: any): number | undefined => {
+ if (!packedGraph.cells?.q) return;
+ const found = packedGraph.cells.q.find(x, y, radius);
+ return found ? found[2] : undefined;
+}
+
+/**
+ * Returns an array of packed cell indexes within a specified radius from given x and y coordinates
+ * @param {number} x - The x coordinate
+ * @param {number} y - The y coordinate
+ */
+export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => {
+ const found = packedGraph.cells.q.findAll(x, y, radius);
+ return found.map((r: any) => r[2]);
+}
+
+/**
+ * Returns the polygon points for a packed cell given its index
+ * @param {number} i - The index of the packed cell
+ * @returns {Array} - An array of polygon points for the specified cell
+ */
+export const getPackPolygon = (cellIndex: number, packedGraph: any) => {
+ return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]);
+}
+
+/**
+ * Returns the polygon points for a grid cell given its index
+ * @param {number} i - The index of the grid cell
+ * @returns {Array} - An array of polygon points for the specified grid cell
+ */
+export const getGridPolygon = (i: number, grid: any) => {
+ return grid.cells.v[i].map((v: number) => grid.vertices.p[v]);
+}
+
+/**
+ * mbostock's poissonDiscSampler implementation
+ * Generates points using Poisson-disc sampling within a specified rectangle
+ * @param {number} x0 - The minimum x coordinate of the rectangle
+ * @param {number} y0 - The minimum y coordinate of the rectangle
+ * @param {number} x1 - The maximum x coordinate of the rectangle
+ * @param {number} y1 - The maximum y coordinate of the rectangle
+ * @param {number} r - The minimum distance between points
+ * @param {number} k - The number of attempts before rejection (default is 3)
+ * @yields {Array} - An array containing the x and y coordinates of a generated point
+ */
+export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: number, r: number, k = 3) {
+ if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error();
+
+ const width = x1 - x0;
+ const height = y1 - y0;
+ const r2 = r * r;
+ const r2_3 = 3 * r2;
+ const cellSize = r * Math.SQRT1_2;
+ const gridWidth = Math.ceil(width / cellSize);
+ const gridHeight = Math.ceil(height / cellSize);
+ const grid = new Array(gridWidth * gridHeight);
+ const queue: [number, number][] = [];
+
+ function far(x: number, y: number) {
+ const i = (x / cellSize) | 0;
+ const j = (y / cellSize) | 0;
+ const i0 = Math.max(i - 2, 0);
+ const j0 = Math.max(j - 2, 0);
+ const i1 = Math.min(i + 3, gridWidth);
+ const j1 = Math.min(j + 3, gridHeight);
+ for (let j = j0; j < j1; ++j) {
+ const o = j * gridWidth;
+ for (let i = i0; i < i1; ++i) {
+ const s = grid[o + i];
+ if (s) {
+ const dx = s[0] - x;
+ const dy = s[1] - y;
+ if (dx * dx + dy * dy < r2) return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ function sample(x: number, y: number) {
+ const point: [number, number] = [x, y];
+ queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point));
+ return [x + x0, y + y0];
+ }
+
+ yield sample(width / 2, height / 2);
+
+ pick: while (queue.length) {
+ const i = (Math.random() * queue.length) | 0;
+ const parent = queue[i];
+
+ for (let j = 0; j < k; ++j) {
+ const a = 2 * Math.PI * Math.random();
+ const r = Math.sqrt(Math.random() * r2_3 + r2);
+ const x = parent[0] + r * Math.cos(a);
+ const y = parent[1] + r * Math.sin(a);
+ if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) {
+ yield sample(x, y);
+ continue pick;
+ }
+ }
+
+ const r = queue.pop();
+ if (r !== undefined && i < queue.length) queue[i] = r;
+ }
+}
+
+/**
+ * Checks if a packed cell is land based on its height
+ * @param {number} i - The index of the packed cell
+ * @returns {boolean} - True if the cell is land, false otherwise
+ */
+export const isLand = (i: number, packedGraph: any) => {
+ return packedGraph.cells.h[i] >= 20;
+}
+
+/**
+ * Checks if a packed cell is water based on its height
+ * @param {number} i - The index of the packed cell
+ * @returns {boolean} - True if the cell is water, false otherwise
+ */
+export const isWater = (i: number, packedGraph: any) => {
+ return packedGraph.cells.h[i] < 20;
+}
+
+export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => {
+ const radiusSearchInit = (t: any, radius: number) => {
+ t.result = [];
+ (t.x0 = t.x - radius), (t.y0 = t.y - radius);
+ (t.x3 = t.x + radius), (t.y3 = t.y + radius);
+ t.radius = radius * radius;
+ };
+
+ const radiusSearchVisit = (t: any, d2: number) => {
+ t.node.data.scanned = true;
+ if (d2 < t.radius) {
+ do {
+ t.result.push(t.node.data);
+ t.node.data.selected = true;
+ } while ((t.node = t.node.next));
+ }
+ };
+
+ class Quad {
+ node: any;
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ constructor(node: any, x0: number, y0: number, x1: number, y1: number) {
+ this.node = node;
+ this.x0 = x0;
+ this.y0 = y0;
+ this.x1 = x1;
+ this.y1 = y1;
+ }
+ }
+
+ const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root};
+ if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
+ radiusSearchInit(t, radius);
+
+ var i = 0;
+ while ((t.q = t.quads.pop())) {
+ i++;
+
+ // Stop searching if this quadrant 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;
+}
+
+// draw raster heightmap preview (not used in main generation)
+/**
+ * Draws a raster heightmap preview based on given heights and rendering options
+ * @param {Object} options - The options for drawing the heightmap
+ * @param {Array} options.heights - The array of height values
+ * @param {number} options.width - The width of the heightmap
+ * @param {number} options.height - The height of the heightmap
+ * @param {Function} options.scheme - The color scheme function for rendering heights
+ * @param {boolean} options.renderOcean - Whether to render ocean heights
+ * @returns {string} - A data URL representing the drawn heightmap image
+ */
+export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heights: number[], width: number, height: number, scheme: (value: number) => string, renderOcean: boolean}) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d")!;
+ const imageData = ctx.createImageData(width, height);
+
+ const getHeight = (height: number) => (height < 20 ? (renderOcean ? height : 0) : height);
+
+ for (let i = 0; i < heights.length; i++) {
+ const colorScheme = scheme(1 - getHeight(heights[i]) / 100);
+ const {r, g, b} = color(colorScheme)!.rgb();
+
+ const n = i * 4;
+ imageData.data[n] = r;
+ imageData.data[n + 1] = g;
+ imageData.data[n + 2] = b;
+ imageData.data[n + 3] = 255;
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ return canvas.toDataURL("image/png");
+}
+
+declare global {
+ interface Window {
+ TIME: boolean;
+ Voronoi: any;
+
+ shouldRegenerateGrid: typeof shouldRegenerateGrid;
+ generateGrid: typeof generateGrid;
+ findCell: typeof findClosestCell;
+ findGridCell: typeof findGridCell;
+ findGridAll: typeof findGridAll;
+ calculateVoronoi: typeof calculateVoronoi;
+ findAll: typeof findAllCellsInRadius;
+ getPackPolygon: typeof getPackPolygon;
+ getGridPolygon: typeof getGridPolygon;
+ poissonDiscSampler: typeof poissonDiscSampler;
+ isLand: typeof isLand;
+ isWater: typeof isWater;
+ findAllInQuadtree: typeof findAllInQuadtree;
+ drawHeights: typeof drawHeights;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 00000000..82439ac7
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,146 @@
+import "./polyfills";
+
+import { rn, lim, minmax, normalize, lerp } from "./numberUtils";
+window.rn = rn;
+window.lim = lim;
+window.minmax = minmax;
+window.normalize = normalize;
+window.lerp = lerp as typeof window.lerp;
+
+import { isVowel, trimVowels, getAdjective, nth, abbreviate, list } from "./languageUtils";
+window.vowel = isVowel;
+window.trimVowels = trimVowels;
+window.getAdjective = getAdjective;
+window.nth = nth;
+window.abbreviate = abbreviate;
+window.list = list;
+
+import { last, unique, deepCopy, getTypedArray, createTypedArray, TYPED_ARRAY_MAX_VALUES } from "./arrayUtils";
+window.last = last;
+window.unique = unique;
+window.deepCopy = deepCopy;
+window.getTypedArray = getTypedArray;
+window.createTypedArray = createTypedArray;
+window.INT8_MAX = TYPED_ARRAY_MAX_VALUES.INT8_MAX;
+window.UINT8_MAX = TYPED_ARRAY_MAX_VALUES.UINT8_MAX;
+window.UINT16_MAX = TYPED_ARRAY_MAX_VALUES.UINT16_MAX;
+window.UINT32_MAX = TYPED_ARRAY_MAX_VALUES.UINT32_MAX;
+
+import { rand, P, each, gauss, Pint, biased, generateSeed, getNumberInRange, ra, rw } from "./probabilityUtils";
+window.rand = rand;
+window.P = P;
+window.each = each;
+window.gauss = gauss;
+window.Pint = Pint;
+window.ra = ra;
+window.rw = rw;
+window.biased = biased;
+window.getNumberInRange = getNumberInRange;
+window.generateSeed = generateSeed;
+
+import { convertTemperature, si, getIntegerFromSI } from "./unitUtils";
+window.convertTemperature = (temp:number, scale: any = (window as any).temperatureScale.value || "°C") => convertTemperature(temp, scale);
+window.si = si;
+window.getInteger = getIntegerFromSI;
+
+import { toHEX, getColors, getRandomColor, getMixedColor, C_12 } from "./colorUtils";
+window.toHEX = toHEX;
+window.getColors = getColors;
+window.getRandomColor = getRandomColor;
+window.getMixedColor = getMixedColor;
+window.C_12 = C_12;
+
+import { getComposedPath, getNextId } from "./nodeUtils";
+window.getComposedPath = getComposedPath;
+window.getNextId = getNextId;
+
+import { rollups, distanceSquared } from "./functionUtils";
+window.rollups = rollups;
+window.dist2 = distanceSquared;
+
+import { getIsolines, getPolesOfInaccessibility, connectVertices, findPath, getVertexPath } from "./pathUtils";
+window.getIsolines = getIsolines;
+window.getPolesOfInaccessibility = getPolesOfInaccessibility;
+window.connectVertices = connectVertices;
+window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack);
+window.getVertexPath = (cellsArray) => getVertexPath(cellsArray, (window as any).pack);
+
+import { round, capitalize, splitInTwo, parseTransform, isValidJSON, safeParseJSON, sanitizeId } from "./stringUtils";
+window.round = round;
+window.capitalize = capitalize;
+window.splitInTwo = splitInTwo;
+window.parseTransform = parseTransform;
+window.sanitizeId = sanitizeId;
+
+JSON.isValid = isValidJSON;
+JSON.safeParse = safeParseJSON;
+
+import { byId } from "./shorthands";
+window.byId = byId;
+Node.prototype.on = function (name, fn, options) {
+ this.addEventListener(name, fn, options);
+ return this;
+};
+Node.prototype.off = function (name, fn) {
+ this.removeEventListener(name, fn);
+ return this;
+};
+
+declare global {
+
+ interface JSON {
+ isValid: (str: string) => boolean;
+ safeParse: (str: string) => any;
+ }
+
+ interface Node {
+ on: (name: string, fn: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => Node;
+ off: (name: string, fn: EventListenerOrEventListenerObject) => Node;
+ }
+}
+
+import { shouldRegenerateGrid, generateGrid, findGridAll, findGridCell, findClosestCell, calculateVoronoi, findAllCellsInRadius, getPackPolygon, getGridPolygon, poissonDiscSampler, isLand, isWater, findAllInQuadtree, drawHeights } from "./graphUtils";
+window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => shouldRegenerateGrid(grid, expectedSeed, (window as any).graphWidth, (window as any).graphHeight);
+window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight);
+window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid);
+window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid);
+window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack);
+window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack);
+window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack);
+window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid);
+window.calculateVoronoi = calculateVoronoi;
+window.poissonDiscSampler = poissonDiscSampler;
+window.findAllInQuadtree = findAllInQuadtree;
+window.drawHeights = drawHeights;
+window.isLand = (i: number) => isLand(i, (window as any).pack);
+window.isWater = (i: number) => isWater(i, (window as any).pack);
+
+import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils";
+window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, (window as any).graphWidth, (window as any).graphHeight, secure);
+window.getSegmentId = getSegmentId;
+window.debounce = debounce;
+window.throttle = throttle;
+window.parseError = parseError;
+window.getBase64 = getBase64;
+window.openURL = openURL;
+window.wiki = wiki;
+window.link = link;
+window.isCtrlClick = isCtrlClick;
+window.generateDate = generateDate;
+window.getLongitude = (x: number, decimals?: number) => getLongitude(x, (window as any).mapCoordinates, (window as any).graphWidth, decimals);
+window.getLatitude = (y: number, decimals?: number) => getLatitude(y, (window as any).mapCoordinates, (window as any).graphHeight, decimals);
+window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, (window as any).mapCoordinates, (window as any).graphWidth, (window as any).graphHeight, decimals);
+
+// Initialize prompt when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializePrompt);
+} else {
+ initializePrompt();
+}
+
+import { drawCellsValue, drawPolygons, drawRouteConnections, drawPoint, drawPath } from "./debugUtils";
+window.drawCellsValue = (data:any[]) => drawCellsValue(data, (window as any).pack);
+window.drawPolygons = (data: any[]) => drawPolygons(data, (window as any).terrs, (window as any).grid);
+window.drawRouteConnections = () => drawRouteConnections((window as any).packedGraph);
+window.drawPoint = drawPoint;
+window.drawPath = drawPath;
\ No newline at end of file
diff --git a/src/utils/languageUtils.ts b/src/utils/languageUtils.ts
new file mode 100644
index 00000000..0fbd20c8
--- /dev/null
+++ b/src/utils/languageUtils.ts
@@ -0,0 +1,217 @@
+import { last } from "./arrayUtils";
+import { P } from "./probabilityUtils";
+
+/**
+ * Check if character is a vowel
+ * @param c - The character to check.
+ * @returns True if the character is a vowel, false otherwise.
+ */
+export const isVowel = (c: string): boolean => {
+ const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`;
+ return VOWELS.includes(c);
+}
+
+/**
+ * Remove trailing vowels from a string until it reaches a minimum length.
+ * @param string - The input string.
+ * @param minLength - The minimum length of the string after trimming (default is 3).
+ * @returns The trimmed string.
+ */
+export const trimVowels = (string: string, minLength: number = 3) => {
+ while (string.length > minLength && isVowel(last(Array.from(string)))) {
+ string = string.slice(0, -1);
+ }
+ return string;
+}
+
+
+/**
+ * Get adjective form of a noun based on predefined rules.
+ * @param noun - The noun to be converted to an adjective.
+ * @returns The adjective form of the noun.
+ */
+export const getAdjective = (nounToBeAdjective: string) => {
+ const adjectivizationRules = [
+ {
+ name: "guo",
+ probability: 1,
+ condition: new RegExp(" Guo$"),
+ action: (noun: string) => noun.slice(0, -4)
+ },
+ {
+ name: "orszag",
+ probability: 1,
+ condition: new RegExp("orszag$"),
+ action: (noun: string) => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6))
+ },
+ {
+ name: "stan",
+ probability: 1,
+ condition: new RegExp("stan$"),
+ action: (noun: string) => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4)))
+ },
+ {
+ name: "land",
+ probability: 1,
+ condition: new RegExp("land$"),
+ action: (noun: string) => {
+ if (noun.length > 9) return noun.slice(0, -4);
+ const root = trimVowels(noun.slice(0, -4), 0);
+ if (root.length < 3) return noun + "ic";
+ if (root.length < 4) return root + "lish";
+ return root + "ish";
+ }
+ },
+ {
+ name: "que",
+ probability: 1,
+ condition: new RegExp("que$"),
+ action: (noun: string) => noun.replace(/que$/, "can")
+ },
+ {
+ name: "a",
+ probability: 1,
+ condition: new RegExp("a$"),
+ action: (noun: string) => noun + "n"
+ },
+ {
+ name: "o",
+ probability: 1,
+ condition: new RegExp("o$"),
+ action: (noun: string) => noun.replace(/o$/, "an")
+ },
+ {
+ name: "u",
+ probability: 1,
+ condition: new RegExp("u$"),
+ action: (noun: string) => noun + "an"
+ },
+ {
+ name: "i",
+ probability: 1,
+ condition: new RegExp("i$"),
+ action: (noun: string) => noun + "an"
+ },
+ {
+ name: "e",
+ probability: 1,
+ condition: new RegExp("e$"),
+ action: (noun: string) => noun + "an"
+ },
+ {
+ name: "ay",
+ probability: 1,
+ condition: new RegExp("ay$"),
+ action: (noun: string) => noun + "an"
+ },
+ {
+ name: "os",
+ probability: 1,
+ condition: new RegExp("os$"),
+ action: (noun: string) => {
+ const root = trimVowels(noun.slice(0, -2), 0);
+ if (root.length < 4) return noun.slice(0, -1);
+ return root + "ian";
+ }
+ },
+ {
+ name: "es",
+ probability: 1,
+ condition: new RegExp("es$"),
+ action: (noun: string) => {
+ const root = trimVowels(noun.slice(0, -2), 0);
+ if (root.length > 7) return noun.slice(0, -1);
+ return root + "ian";
+ }
+ },
+ {
+ name: "l",
+ probability: 0.8,
+ condition: new RegExp("l$"),
+ action: (noun: string) => noun + "ese"
+ },
+ {
+ name: "n",
+ probability: 0.8,
+ condition: new RegExp("n$"),
+ action: (noun: string) => noun + "ese"
+ },
+ {
+ name: "ad",
+ probability: 0.8,
+ condition: new RegExp("ad$"),
+ action: (noun: string) => noun + "ian"
+ },
+ {
+ name: "an",
+ probability: 0.8,
+ condition: new RegExp("an$"),
+ action: (noun: string) => noun + "ian"
+ },
+ {
+ name: "ish",
+ probability: 0.25,
+ condition: new RegExp("^[a-zA-Z]{6}$"),
+ action: (noun: string) => trimVowels(noun.slice(0, -1)) + "ish"
+ },
+ {
+ name: "an",
+ probability: 0.5,
+ condition: new RegExp("^[a-zA-Z]{0,7}$"),
+ action: (noun: string) => trimVowels(noun) + "an"
+ }
+ ];
+ for (const rule of adjectivizationRules) {
+ if (P(rule.probability) && rule.condition.test(nounToBeAdjective)) {
+ return rule.action(nounToBeAdjective);
+ }
+ }
+ return nounToBeAdjective; // no rule applied, return noun as is
+}
+
+/**
+ * Get the ordinal suffix for a given number.
+ * @param n - The number.
+ * @returns The number with its ordinal suffix.
+ */
+export const nth = (n: number) => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th");
+
+/**
+ * Generate an abbreviation for a given name, avoiding restricted codes.
+ * @param name - The name to be abbreviated.
+ * @param restricted - An array of restricted abbreviations to avoid (default is an empty array).
+ * @returns The generated abbreviation.
+ */
+export const abbreviate = (name: string, restricted: string[] = []) => {
+ const parsed = name.replace("Old ", "O ").replace(/[()]/g, ""); // remove Old prefix and parentheses
+ const words = parsed.split(" ");
+ const letters = words.join("");
+
+ let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2);
+ for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) {
+ code = letters[0] + letters[i].toUpperCase();
+ }
+ return code;
+}
+
+/**
+ * Format a list of strings into a human-readable list.
+ * @param array - The array of strings to be formatted.
+ * @returns The formatted list as a string.
+ */
+export const list = (array: string[]) => {
+ if (!Intl.ListFormat) return array.join(", ");
+ const conjunction = new Intl.ListFormat(document.documentElement.lang || "en", {style: "long", type: "conjunction"});
+ return conjunction.format(array);
+}
+
+declare global {
+ interface Window {
+ vowel: typeof isVowel;
+ trimVowels: typeof trimVowels;
+ getAdjective: typeof getAdjective;
+ nth: typeof nth;
+ abbreviate: typeof abbreviate;
+ list: typeof list;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/nodeUtils.ts b/src/utils/nodeUtils.ts
new file mode 100644
index 00000000..6213840f
--- /dev/null
+++ b/src/utils/nodeUtils.ts
@@ -0,0 +1,31 @@
+/**
+ * Get the composed path of a node (including shadow DOM and window)
+ * @param {Node | Window} node - The starting node or window
+ * @returns {Array} - The composed path as an array
+ */
+export const getComposedPath = function(node: any): Array {
+ let parent;
+ if (node.parentNode) parent = node.parentNode;
+ else if (node.host) parent = node.host;
+ else if (node.defaultView) parent = node.defaultView;
+ if (parent !== undefined) return [node].concat(getComposedPath(parent));
+ return [node];
+}
+
+/**
+ * Generate a unique ID for a given core string
+ * @param {string} core - The core string for the ID
+ * @param {number} [i=1] - The starting index
+ * @returns {string} - The unique ID
+ */
+export const getNextId = function(core: string, i: number = 1): string {
+ while (document.getElementById(core + i)) i++;
+ return core + i;
+}
+
+declare global {
+ interface Window {
+ getComposedPath: typeof getComposedPath;
+ getNextId: typeof getNextId;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/numberUtils.ts b/src/utils/numberUtils.ts
new file mode 100644
index 00000000..a2ab6220
--- /dev/null
+++ b/src/utils/numberUtils.ts
@@ -0,0 +1,62 @@
+/**
+ * Rounds a number to a specified number of decimal places.
+ * @param v - The number to be rounded.
+ * @param d - The number of decimal places to round to (default is 0).
+ * @returns The rounded number.
+ */
+export const rn = (v: number, d: number = 0) => {
+ const m = Math.pow(10, d);
+ return Math.round(v * m) / m;
+}
+
+/**
+ * Clamps a number between a minimum and maximum value.
+ * @param value - The number to be clamped.
+ * @param min - The minimum value.
+ * @param max - The maximum value.
+ * @returns The clamped number.
+ */
+export const minmax = (value: number, min: number, max: number) => {
+ return Math.min(Math.max(value, min), max);
+}
+
+/**
+ * Clamps a number between 0 and 100.
+ * @param v - The number to be clamped.
+ * @returns The clamped number.
+ */
+export const lim = (v: number) => {
+ return minmax(v, 0, 100);
+}
+
+/**
+ * Normalizes a number within a specified range to a value between 0 and 1.
+ * @param val - The number to be normalized.
+ * @param min - The minimum value of the range.
+ * @param max - The maximum value of the range.
+ * @returns The normalized number.
+ */
+export const normalize = (val: number, min: number, max: number) => {
+ return minmax((val - min) / (max - min), 0, 1);
+}
+
+/**
+ * Performs linear interpolation between two values.
+ * @param a - The starting value.
+ * @param b - The ending value.
+ * @param t - The interpolation factor (between 0 and 1).
+ * @returns The interpolated value.
+ */
+export const lerp = (a: number, b: number, t: number) => {
+ return a + (b - a) * t;
+}
+
+declare global {
+ interface Window {
+ rn: typeof rn;
+ minmax: typeof minmax;
+ lim: typeof lim;
+ normalize: typeof normalize;
+ lerp: typeof lerp;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts
new file mode 100644
index 00000000..b37f17fb
--- /dev/null
+++ b/src/utils/pathUtils.ts
@@ -0,0 +1,300 @@
+import polylabel from "polylabel";
+import { rn } from "./numberUtils";
+
+/**
+ * Generates SVG path data for filling a shape defined by a chain of vertices.
+ * @param {object} vertices - The vertices object containing positions.
+ * @param {number[]} vertexChain - An array of vertex IDs defining the shape.
+ * @returns {string} SVG path data for the filled shape.
+ */
+const getFillPath = (vertices: any, vertexChain: number[]) => {
+ const points = vertexChain.map(vertexId => vertices.p[vertexId]);
+ const firstPoint = points.shift();
+ return `M${firstPoint} L${points.join(" ")} Z`;
+}
+
+/**
+ * Generates SVG path data for borders based on a chain of vertices and a discontinuation condition.
+ * @param {object} vertices - The vertices object containing positions.
+ * @param {number[]} vertexChain - An array of vertex IDs defining the border.
+ * @param {(vertexId: number) => boolean} discontinue - A function that determines if the path should discontinue at a vertex.
+ * @returns {string} SVG path data for the border.
+ */
+const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (vertexId: number) => boolean) => {
+ let discontinued = true;
+ let lastOperation = "";
+ const path = vertexChain.map(vertexId => {
+ if (discontinue(vertexId)) {
+ discontinued = true;
+ return "";
+ }
+
+ const operation = discontinued ? "M" : "L";
+ discontinued = false;
+ lastOperation = operation;
+
+ const command = operation === "L" && operation === lastOperation ? "" : operation;
+ return ` ${command}${vertices.p[vertexId]}`;
+ });
+
+ return path.join("").trim();
+}
+
+/**
+ * Restores the path from exit to start using the 'from' mapping.
+ * @param {number} exit - The ID of the exit cell.
+ * @param {number} start - The ID of the starting cell.
+ * @param {number[]} from - An array mapping each cell ID to the cell ID it came from.
+ * @returns {number[]} An array of cell IDs representing the path from start to exit.
+ */
+const restorePath = (exit: number, start: number, from: number[]) => {
+ const pathCells = [];
+
+ let current = exit;
+ let prev = exit;
+
+ while (current !== start) {
+ pathCells.push(current);
+ prev = from[current];
+ current = prev;
+ }
+
+ pathCells.push(current);
+
+ return pathCells.reverse();
+}
+
+/**
+ * Returns isolines (borders) for different types of cells in the graph.
+ * @param {object} graph - The graph object containing cells and vertices.
+ * @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID.
+ * @param {object} [options] - Options to specify which isoline formats to generate.
+ * @param {boolean} [options.polygons=false] - Whether to generate polygons for each type.
+ * @param {boolean} [options.fill=false] - Whether to generate fill paths for each type.
+ * @param {boolean} [options.halo=false] - Whether to generate halo paths for each type.
+ * @param {boolean} [options.waterGap=false] - Whether to generate water gap paths for each type.
+ * @returns {object} An object containing isolines for each type based on the specified options.
+ */
+export const getIsolines = (graph: any, getType: (cellId: number) => any, options: {polygons?: boolean, fill?: boolean, halo?: boolean, waterGap?: boolean} = {polygons: false, fill: false, halo: false, waterGap: false}): any => {
+ const {cells, vertices} = graph;
+ const isolines: any = {};
+
+ const checkedCells = new Uint8Array(cells.i.length);
+ const addToChecked = (cellId: number) => (checkedCells[cellId] = 1);
+ const isChecked = (cellId: number) => checkedCells[cellId] === 1;
+
+ for (const cellId of cells.i) {
+ if (isChecked(cellId) || !getType(cellId)) continue;
+ addToChecked(cellId);
+
+ const type = getType(cellId);
+ const ofSameType = (cellId: number) => getType(cellId) === type;
+ const ofDifferentType = (cellId: number) => getType(cellId) !== type;
+
+ const onborderCell = cells.c[cellId].find(ofDifferentType);
+ if (onborderCell === undefined) continue;
+
+ // check if inner lake. Note there is no shoreline for grid features
+ const feature = graph.features[cells.f[onborderCell]];
+ if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue;
+
+ const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType));
+ if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
+
+ const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
+ if (vertexChain.length < 3) continue;
+
+ addIsolineTo(type, vertices, vertexChain, isolines, options);
+ }
+
+ return isolines;
+
+ function addIsolineTo(type: any, vertices: any, vertexChain: number[], isolines: any, options: any) {
+ if (!isolines[type]) isolines[type] = {};
+
+ if (options.polygons) {
+ if (!isolines[type].polygons) isolines[type].polygons = [];
+ isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId]));
+ }
+
+ if (options.fill) {
+ if (!isolines[type].fill) isolines[type].fill = "";
+ isolines[type].fill += getFillPath(vertices, vertexChain);
+ }
+
+ if (options.waterGap) {
+ if (!isolines[type].waterGap) isolines[type].waterGap = "";
+ const isLandVertex = (vertexId: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20);
+ isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex);
+ }
+
+ if (options.halo) {
+ if (!isolines[type].halo) isolines[type].halo = "";
+ const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]);
+ isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex);
+ }
+ }
+}
+
+
+/**
+ * Generates SVG path data for the border of a shape defined by a chain of vertices.
+ * @param {number[]} cellsArray - An array of cell IDs defining the shape.
+ * @param {object} packedGraph - The packed graph object containing cells and vertices.
+ * @returns {string} SVG path data for the border of the shape.
+ */
+export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => {
+ const {cells, vertices} = packedGraph;
+
+ const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true]));
+ const ofSameType = (cellId: number) => cellsObj[cellId];
+ const ofDifferentType = (cellId: number) => !cellsObj[cellId];
+
+ const checkedCells = new Uint8Array(cells.c.length);
+ const addToChecked = (cellId: number) => (checkedCells[cellId] = 1);
+ const isChecked = (cellId: number) => checkedCells[cellId] === 1;
+ let path = "";
+
+ for (const cellId of cellsArray) {
+ if (isChecked(cellId)) continue;
+
+ const onborderCell = cells.c[cellId].find(ofDifferentType);
+ if (onborderCell === undefined) continue;
+
+ const feature = packedGraph.features[cells.f[onborderCell]];
+ if (feature.type === "lake" && feature.shoreline) {
+ if (feature.shoreline.every(ofSameType)) continue; // inner lake
+ }
+
+ const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType));
+ if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
+
+ const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
+ if (vertexChain.length < 3) continue;
+
+ path += getFillPath(vertices, vertexChain);
+ }
+
+ return path;
+}
+
+/**
+ * Finds the poles of inaccessibility for each type of cell in the graph.
+ * @param {object} graph - The graph object containing cells and vertices.
+ * @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID.
+ * @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y].
+ */
+export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => {
+ const isolines = getIsolines(graph, getType, {polygons: true});
+
+ const poles = Object.entries(isolines).map(([id, isoline]) => {
+ const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length);
+ const [x, y] = polylabel(multiPolygon, 20);
+ return [id, [rn(x), rn(y)]];
+ });
+
+ return Object.fromEntries(poles);
+}
+
+/**
+ * Connects vertices to form a closed path based on cell type.
+ * @param {object} options - Options for connecting vertices.
+ * @param {object} options.vertices - The vertices object containing connections.
+ * @param {number} options.startingVertex - The ID of the starting vertex.
+ * @param {(cellId: number) => boolean} options.ofSameType - A function that checks if a cell is of the same type.
+ * @param {(cellId: number) => void} [options.addToChecked] - A function to mark cells as checked.
+ * @param {boolean} [options.closeRing=false] - Whether to close the path into a ring.
+ * @returns {number[]} An array of vertex IDs forming the connected path.
+ */
+export const connectVertices = ({vertices, startingVertex, ofSameType, addToChecked, closeRing}: {vertices: any, startingVertex: number, ofSameType: (cellId: number) => boolean, addToChecked?: (cellId: number) => void, closeRing?: boolean}) => {
+ const MAX_ITERATIONS = vertices.c.length;
+ const chain = []; // vertices chain to form a path
+
+ let next = startingVertex;
+ for (let i = 0; i === 0 || next !== startingVertex; i++) {
+ const previous = chain.at(-1);
+ const current = next;
+ chain.push(current);
+
+ const neibCells = vertices.c[current];
+ if (addToChecked) neibCells.filter(ofSameType).forEach(addToChecked);
+
+ const [c1, c2, c3] = neibCells.map(ofSameType);
+ const [v1, v2, v3] = vertices.v[current];
+
+ if (v1 !== previous && c1 !== c2) next = v1;
+ else if (v2 !== previous && c2 !== c3) next = v2;
+ else if (v3 !== previous && c1 !== c3) next = v3;
+
+ if (next >= vertices.c.length) {
+ window.ERROR && console.error("ConnectVertices: next vertex is out of bounds");
+ break;
+ }
+
+ if (next === current) {
+ window.ERROR && console.error("ConnectVertices: next vertex is not found");
+ break;
+ }
+
+ if (i === MAX_ITERATIONS) {
+ window.ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS);
+ break;
+ }
+ }
+
+ if (closeRing) chain.push(startingVertex);
+ return chain;
+}
+
+/**
+ * Finds the shortest path between two cells using a cost-based pathfinding algorithm.
+ * @param {number} start - The ID of the starting cell.
+ * @param {(id: number) => boolean} isExit - A function that returns true if the cell is the exit cell.
+ * @param {(current: number, next: number) => number} getCost - A function that returns the path cost from current cell to the next cell. Must return `Infinity` for impassable connections.
+ * @param {object} packedGraph - The packed graph object containing cells and their connections.
+ * @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same.
+ */
+export const findPath = (start: number, isExit: (id: number) => boolean, getCost: (current: number, next: number) => number, packedGraph: any = {}): number[] | null => {
+ if (isExit(start)) return null;
+
+ const from = [];
+ const cost = [];
+ const queue = new window.FlatQueue();
+ queue.push(start, 0);
+
+ while (queue.length) {
+ const currentCost = queue.peekValue();
+ const current = queue.pop();
+
+ for (const next of packedGraph.cells.c[current]) {
+ if (isExit(next)) {
+ from[next] = current;
+ return restorePath(next, start, from);
+ }
+
+ const nextCost = getCost(current, next);
+ if (nextCost === Infinity) continue; // impassable cell
+ const totalCost = currentCost + nextCost;
+
+ if (totalCost >= cost[next]) continue; // has cheaper path
+ from[next] = current;
+ cost[next] = totalCost;
+ queue.push(next, totalCost);
+ }
+ }
+
+ return null;
+}
+
+declare global {
+ interface Window {
+ ERROR: boolean;
+ FlatQueue: any;
+
+ getIsolines: typeof getIsolines;
+ getPolesOfInaccessibility: typeof getPolesOfInaccessibility;
+ connectVertices: typeof connectVertices;
+ findPath: typeof findPath;
+ getVertexPath: typeof getVertexPath;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts
new file mode 100644
index 00000000..18f5f1bd
--- /dev/null
+++ b/src/utils/polyfills.ts
@@ -0,0 +1,54 @@
+// replaceAll
+if (String.prototype.replaceAll === undefined) {
+ String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string {
+ if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any);
+ return this.replace(new RegExp(str, "g"), newStr as any);
+ };
+}
+
+// flat
+if (Array.prototype.flat === undefined) {
+ Array.prototype.flat = function (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)), []);
+ };
+}
+
+// at
+if (Array.prototype.at === undefined) {
+ Array.prototype.at = function (this: T[], index: number): T | undefined {
+ if (index < 0) index += this.length;
+ if (index < 0 || index >= this.length) return undefined;
+ return this[index];
+ };
+}
+
+// readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10
+if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) {
+ (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* (this: ReadableStream): AsyncGenerator {
+ const reader = this.getReader();
+ try {
+ while (true) {
+ const {done, value} = await reader.read();
+ if (done) return;
+ yield value;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ };
+}
+
+declare global {
+ interface String {
+ replaceAll(searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string;
+ }
+
+ interface Array {
+ flat(depth?: number): T[];
+ at(index: number): T | undefined;
+ }
+
+ interface ReadableStream {
+ [Symbol.asyncIterator](): AsyncIterableIterator;
+ }
+}
diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts
new file mode 100644
index 00000000..4f6fd66a
--- /dev/null
+++ b/src/utils/probabilityUtils.ts
@@ -0,0 +1,147 @@
+import { minmax, rn } from "./numberUtils";
+import { randomNormal } from "d3";
+
+/**
+ * Creates a random number between min and max (inclusive).
+ * @param {number} min - minimum value
+ * @param {number} max - maximum value
+ * @return {number} random integer between min and max
+ */
+export const rand = (min: number, max?: number): number => {
+ if (min === undefined && max === undefined) return Math.random();
+ if (max === undefined) {
+ max = min;
+ min = 0;
+ }
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+/**
+ * Returns a boolean based on the given probability.
+ * @param {number} probability - probability between 0 and 1
+ * @return {boolean} true with the given probability
+ */
+export const P = (probability: number): boolean => {
+ if (probability >= 1) return true;
+ if (probability <= 0) return false;
+ return Math.random() < probability;
+}
+
+/**
+ * Returns true every n times.
+ * @param {number} n - the interval
+ * @return {function} function that takes the current index and returns true every n times
+ */
+export const each = (n: number) => {
+ return (i: number) => i % n === 0;
+}
+
+/**
+ * Random Gaussian number generator
+ * @param {number} expected - expected value
+ * @param {number} deviation - standard deviation
+ * @param {number} min - minimum value
+ * @param {number} max - maximum value
+ * @param {number} round - round value to n decimals
+ * @return {number} random number
+ */
+export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => {
+ return rn(minmax(randomNormal(expected, deviation)(), min, max), round);
+}
+
+/**
+ * Returns the integer part of a float plus one with the probability of the decimal part.
+ * @param {number} float - the float number
+ * @return {number} the resulting integer
+ */
+export const Pint = (float: number): number => {
+ return ~~float + +P(float % 1);
+}
+
+/**
+ * Returns a random element from an array.
+ * @param {Array} array - the array to pick from
+ * @return {any} a random element from the array
+ */
+export const ra = (array: any[]): any => {
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+/**
+ * Returns a random key from an object where values are weights.
+ * @param {Object} object - object with keys and their weights
+ * @return {string} a random key based on weights
+ *
+ * @example
+ * const obj = { a: 1, b: 3, c: 6 };
+ * const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance
+ */
+export const rw = (object: {[key: string]: number}): string => {
+ const array = [];
+ for (const key in object) {
+ for (let i = 0; i < object[key]; i++) {
+ array.push(key);
+ }
+ }
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+/**
+ * Returns a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min).
+ * @param {number} min - minimum value
+ * @param {number} max - maximum value
+ * @param {number} ex - exponent for bias
+ * @return {number} biased random integer
+ */
+export const biased = (min: number, max: number, ex: number): number => {
+ return Math.round(min + (max - min) * Math.pow(Math.random(), ex));
+}
+
+const ERROR = false;
+/**
+ * Get number from string in format "1-3" or "2" or "0.5"
+ * @param {string} r - range string
+ * @return {number} parsed number
+ */
+export const getNumberInRange = (r: string): number => {
+ if (typeof r !== "string") {
+ ERROR && console.error("Range value should be a string", r);
+ return 0;
+ }
+ if (!isNaN(+r)) return ~~r + +P(+r - ~~r);
+ const sign = r[0] === "-" ? -1 : 1;
+ if (isNaN(+r[0])) r = r.slice(1);
+ const range = r.includes("-") ? r.split("-") : null;
+ if (!range) {
+ ERROR && console.error("Cannot parse the number. Check the format", r);
+ return 0;
+ }
+ const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1]));
+ if (isNaN(count) || count < 0) {
+ ERROR && console.error("Cannot parse number. Check the format", r);
+ return 0;
+ }
+ return count;
+}
+/**
+ * Generate a random seed string
+ * @return {string} random seed
+ */
+export const generateSeed = (): string => {
+ return String(Math.floor(Math.random() * 1e9));
+}
+
+declare global {
+ interface Window {
+ rand: typeof rand;
+ P: typeof P;
+ each: typeof each;
+ gauss: typeof gauss;
+ Pint: typeof Pint;
+ ra: typeof ra;
+ rw: typeof rw;
+ biased: typeof biased;
+ getNumberInRange: typeof getNumberInRange;
+ generateSeed: typeof generateSeed;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/shorthands.ts b/src/utils/shorthands.ts
new file mode 100644
index 00000000..79d0866b
--- /dev/null
+++ b/src/utils/shorthands.ts
@@ -0,0 +1,7 @@
+export const byId = document.getElementById.bind(document);
+
+declare global {
+ interface Window {
+ byId: typeof byId;
+ }
+}
diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts
new file mode 100644
index 00000000..02c4d42a
--- /dev/null
+++ b/src/utils/stringUtils.ts
@@ -0,0 +1,125 @@
+import { rn } from "./numberUtils";
+
+/**
+ * Round all numbers in a string to d decimal places
+ * @param {string} inputString - The input string
+ * @param {number} decimals - Number of decimal places (default is 1)
+ * @returns {string} - The string with rounded numbers
+ */
+export const round = (inputString: string, decimals: number = 1) => {
+ return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => {
+ return rn(parseFloat(n), decimals).toString();
+ });
+}
+
+/**
+ * Capitalize the first letter of a string
+ * @param {string} inputString - The input string
+ * @returns {string} - The capitalized string
+ */
+export const capitalize = (inputString: string) => {
+ return inputString.charAt(0).toUpperCase() + inputString.slice(1);
+}
+
+/**
+ * Split a string into two parts, trying to balance their lengths
+ * @param {string} inputString - The input string
+ * @returns {[string, string]} - An array with two parts of the string
+ */
+export const splitInTwo = (inputString: string): string[] => {
+ const half = inputString.length / 2;
+ const ar = inputString.split(" ");
+ if (ar.length < 2) return ar; // only one word
+ let first = "",
+ last = "",
+ middle = "",
+ rest = "";
+
+ ar.forEach((w, d) => {
+ if (d + 1 !== ar.length) w += " ";
+ rest += w;
+ if (!first || rest.length < half) first += w;
+ else if (!middle) middle = w;
+ else last += w;
+ });
+
+ if (!last) return [first, middle];
+ if (first.length < last.length) return [first + middle, last];
+ return [first, middle + last];
+}
+
+/**
+ * Parse an SVG transform string into an array of numbers
+ * @param {string} string - The SVG transform string
+ * @returns {[number, number, number, number, number, number]} - The parsed transform as an array
+ *
+ * @example
+ * parseTransform("matrix(1, 0, 0, 1, 100, 200)") // returns [1, 0, 0, 1, 100, 200]
+ * parseTransform("translate(50, 75)") // returns [50, 75, 0, 0, 0, 1]
+ */
+export const parseTransform = (string: string) => {
+ if (!string) return [0, 0, 0, 0, 0, 1];
+
+ const a = string
+ .replace(/[a-z()]/g, "")
+ .replace(/[ ]/g, ",")
+ .split(",");
+ return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1];
+}
+
+/**
+ * Check if a string is valid JSON
+ * @param {string} str - The string to check
+ * @returns {boolean} - True if the string is valid JSON, false otherwise
+ */
+export const isValidJSON = (str: string): boolean => {
+ try {
+ JSON.parse(str);
+ return true;
+ } catch (e) {
+ return false;
+ }
+};
+
+/**
+ * Safely parse a JSON string
+ * @param {string} str - The JSON string to parse
+ * @returns {any|null} - The parsed object, or null if parsing failed
+ */
+export const safeParseJSON = (str: string) => {
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return null;
+ }
+};
+
+/**
+ * Sanitize a string to be used as an ID
+ * @param {string} string - The input string
+ * @returns {string} - The sanitized ID string
+ */
+export const sanitizeId = (inputString: string) => {
+ if (!inputString) throw new Error("No string provided");
+
+ let sanitized = inputString
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9-_]/g, "") // no invalid characters
+ .replace(/\s+/g, "-"); // replace spaces with hyphens
+
+ // remove leading numbers
+ if (sanitized.match(/^\d/)) sanitized = "_" + sanitized;
+
+ return sanitized;
+}
+
+declare global {
+ interface Window {
+ round: typeof round;
+ capitalize: typeof capitalize;
+ splitInTwo: typeof splitInTwo;
+ parseTransform: typeof parseTransform;
+ sanitizeId: typeof sanitizeId;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/unitUtils.ts b/src/utils/unitUtils.ts
new file mode 100644
index 00000000..072c0b38
--- /dev/null
+++ b/src/utils/unitUtils.ts
@@ -0,0 +1,57 @@
+import { rn } from "./numberUtils";
+
+type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" | "°Rø";
+/**
+ * Convert temperature from Celsius to other scales
+ * @param {number} temperatureInCelsius - Temperature in Celsius
+ * @param {string} targetScale - Target temperature scale
+ * @returns {string} - Converted temperature with unit
+ */
+export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => {
+ const temperatureConversionMap: {[key: string]: (temp: number) => string} = {
+ "°C": (temp: number) => rn(temp) + "°C",
+ "°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F",
+ K: (temp: number) => rn(temp + 273.15) + "K",
+ "°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R",
+ "°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De",
+ "°N": (temp: number) => rn((temp * 33) / 100) + "°N",
+ "°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré",
+ "°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø"
+ };
+ return temperatureConversionMap[targetScale](temperatureInCelsius);
+}
+
+/**
+ * Convert number to short string with SI postfix
+ * @param {number} n - The number to convert
+ * @returns {string} - The converted string
+ */
+export const si = (n: number): string => {
+ if (n >= 1e9) return rn(n / 1e9, 1) + "B";
+ if (n >= 1e8) return rn(n / 1e6) + "M";
+ if (n >= 1e6) return rn(n / 1e6, 1) + "M";
+ if (n >= 1e4) return rn(n / 1e3) + "K";
+ if (n >= 1e3) return rn(n / 1e3, 1) + "K";
+ return rn(n).toString();
+}
+
+/**
+ * Convert string with SI postfix to integer
+ * @param {string} value - The string to convert
+ * @returns {number} - The converted integer
+ */
+export const getIntegerFromSI = (value: string): number => {
+ const metric = value.slice(-1);
+ if (metric === "K") return parseInt(value.slice(0, -1)) * 1e3;
+ if (metric === "M") return parseInt(value.slice(0, -1)) * 1e6;
+ if (metric === "B") return parseInt(value.slice(0, -1)) * 1e9;
+ return parseInt(value);
+}
+
+declare global {
+ interface Window {
+ convertTemperature: typeof convertTemperature;
+ si: typeof si;
+ getInteger: typeof getIntegerFromSI;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..8b583a9d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "target": "esnext",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+ "isolatedModules": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
\ No newline at end of file