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