diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f3b2b6..0d53220 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,9 @@ name: build on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build: @@ -14,18 +14,23 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - - run: npm ci - - run: npm run build --if-present + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build --if-present env: CI: true test: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - - run: npm ci + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps - run: npm test timeout-minutes: 5 diff --git a/.gitignore b/.gitignore index b11bab5..1424386 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ node_modules/* docs stats.html + +tests/__screenshots__ diff --git a/package-lock.json b/package-lock.json index 6afbbee..ecc7e76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "dom-2d-camera": "~2.2.5", "gl-matrix": "~3.4.3", "pub-sub-es": "~3.0.0", - "regl": "~2.1.0", + "regl": "~2.1.1", "regl-line": "~1.1.1" }, "devDependencies": { @@ -28,7 +28,9 @@ "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.3", "@types/d3-scale": "^4.0.6", - "@types/node": "^22.9.0", + "@types/node": "^22.9.3", + "@vitest/browser": "^2.1.5", + "@vitest/coverage-v8": "^2.1.5", "apache-arrow": "^18.0.0", "browser-env": "^3.3.0", "d3-axis": "^3.0.0", @@ -38,13 +40,15 @@ "esm": "^3.2.25", "gh-pages": "^6.2.0", "merge": "^2.1.1", - "rollup": "^4.25.0", + "playwright": "^1.49.0", + "rollup": "^4.27.4", "rollup-plugin-filesize": "^10.0.0", "tap-spec": "^5.0.0", "tape-run": "^11.0.0", - "typescript": "~5.6.3", + "typescript": "~5.7.2", "vite": "^5.4.11", "vite-plugin-virtual-html-template": "^1.1.0", + "vitest": "^2.1.5", "zora": "^4.1.0" }, "engines": { @@ -53,7 +57,7 @@ }, "peerDependencies": { "pub-sub-es": "~3.0.0", - "regl": "~2.1.0" + "regl": "~2.1.1" } }, "node_modules/@ampproject/remapping": { @@ -1523,6 +1527,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@biomejs/biome": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", @@ -1678,6 +1688,58 @@ "node": ">=14.21.3" } }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", @@ -2078,6 +2140,89 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "node_modules/@inquirer/confirm": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.0.2.tgz", + "integrity": "sha512-KJLUHOaKnNCYzwVbryj3TNBxyZIrr56fR5N45v6K9IPrbT6B7DcudBMfylkV1A8PUdJE15mybkEQyp2/ZUpxUA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.0", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.0.tgz", + "integrity": "sha512-I+ETk2AL+yAVbvuKx5AJpQmoaWhpiTFOg/UJb7ZkMAK4blmtG8ATh5ct+T/8xNld0CZG/2UhtkdMwpgvld92XQ==", + "dev": true, + "dependencies": { + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", + "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2168,6 +2313,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2211,9 +2365,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -2237,6 +2391,23 @@ "through": "~2.3.4" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.1.tgz", + "integrity": "sha512-SvE+tSpcX884RJrPCskXxoS965Ky/pYABDEhWW6oeSRhpUDLrS5nTvT5n1LLSDVDYvty4imVmXsy+3/ROVuknA==", + "dev": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2448,6 +2619,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2458,6 +2651,12 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "dev": true + }, "node_modules/@rollup/plugin-babel": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", @@ -2625,9 +2824,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.25.0.tgz", - "integrity": "sha512-CC/ZqFZwlAIbU1wUPisHyV/XRc5RydFrNLtgl3dGYskdwPZdt4HERtKm50a/+DtTlKeCq9IXFEWR+P6blwjqBA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.4.tgz", + "integrity": "sha512-2Y3JT6f5MrQkICUyRVCw4oa0sutfAsgaSsb0Lmmy1Wi2y7X5vT9Euqw4gOsCyy0YfKURBg35nhUKZS4mDcfULw==", "cpu": [ "arm" ], @@ -2638,9 +2837,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.25.0.tgz", - "integrity": "sha512-/Y76tmLGUJqVBXXCfVS8Q8FJqYGhgH4wl4qTA24E9v/IJM0XvJCGQVSW1QZ4J+VURO9h8YCa28sTFacZXwK7Rg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.4.tgz", + "integrity": "sha512-wzKRQXISyi9UdCVRqEd0H4cMpzvHYt1f/C3CoIjES6cG++RHKhrBj2+29nPF0IB5kpy9MS71vs07fvrNGAl/iA==", "cpu": [ "arm64" ], @@ -2651,9 +2850,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.25.0.tgz", - "integrity": "sha512-YVT6L3UrKTlC0FpCZd0MGA7NVdp7YNaEqkENbWQ7AOVOqd/7VzyHpgIpc1mIaxRAo1ZsJRH45fq8j4N63I/vvg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.4.tgz", + "integrity": "sha512-PlNiRQapift4LNS8DPUHuDX/IdXiLjf8mc5vdEmUR0fF/pyy2qWwzdLjB+iZquGr8LuN4LnUoSEvKRwjSVYz3Q==", "cpu": [ "arm64" ], @@ -2664,9 +2863,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.25.0.tgz", - "integrity": "sha512-ZRL+gexs3+ZmmWmGKEU43Bdn67kWnMeWXLFhcVv5Un8FQcx38yulHBA7XR2+KQdYIOtD0yZDWBCudmfj6lQJoA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.4.tgz", + "integrity": "sha512-o9bH2dbdgBDJaXWJCDTNDYa171ACUdzpxSZt+u/AAeQ20Nk5x+IhA+zsGmrQtpkLiumRJEYef68gcpn2ooXhSQ==", "cpu": [ "x64" ], @@ -2677,9 +2876,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.25.0.tgz", - "integrity": "sha512-xpEIXhiP27EAylEpreCozozsxWQ2TJbOLSivGfXhU4G1TBVEYtUPi2pOZBnvGXHyOdLAUUhPnJzH3ah5cqF01g==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.4.tgz", + "integrity": "sha512-NBI2/i2hT9Q+HySSHTBh52da7isru4aAAo6qC3I7QFVsuhxi2gM8t/EI9EVcILiHLj1vfi+VGGPaLOUENn7pmw==", "cpu": [ "arm64" ], @@ -2690,9 +2889,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.25.0.tgz", - "integrity": "sha512-sC5FsmZGlJv5dOcURrsnIK7ngc3Kirnx3as2XU9uER+zjfyqIjdcMVgzy4cOawhsssqzoAX19qmxgJ8a14Qrqw==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.4.tgz", + "integrity": "sha512-wYcC5ycW2zvqtDYrE7deary2P2UFmSh85PUpAx+dwTCO9uw3sgzD6Gv9n5X4vLaQKsrfTSZZ7Z7uynQozPVvWA==", "cpu": [ "x64" ], @@ -2703,9 +2902,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.25.0.tgz", - "integrity": "sha512-uD/dbLSs1BEPzg564TpRAQ/YvTnCds2XxyOndAO8nJhaQcqQGFgv/DAVko/ZHap3boCvxnzYMa3mTkV/B/3SWA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.4.tgz", + "integrity": "sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==", "cpu": [ "arm" ], @@ -2716,9 +2915,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.25.0.tgz", - "integrity": "sha512-ZVt/XkrDlQWegDWrwyC3l0OfAF7yeJUF4fq5RMS07YM72BlSfn2fQQ6lPyBNjt+YbczMguPiJoCfaQC2dnflpQ==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.4.tgz", + "integrity": "sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==", "cpu": [ "arm" ], @@ -2729,9 +2928,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.25.0.tgz", - "integrity": "sha512-qboZ+T0gHAW2kkSDPHxu7quaFaaBlynODXpBVnPxUgvWYaE84xgCKAPEYE+fSMd3Zv5PyFZR+L0tCdYCMAtG0A==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.4.tgz", + "integrity": "sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==", "cpu": [ "arm64" ], @@ -2742,9 +2941,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.25.0.tgz", - "integrity": "sha512-ndWTSEmAaKr88dBuogGH2NZaxe7u2rDoArsejNslugHZ+r44NfWiwjzizVS1nUOHo+n1Z6qV3X60rqE/HlISgw==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.4.tgz", + "integrity": "sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==", "cpu": [ "arm64" ], @@ -2755,9 +2954,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.25.0.tgz", - "integrity": "sha512-BVSQvVa2v5hKwJSy6X7W1fjDex6yZnNKy3Kx1JGimccHft6HV0THTwNtC2zawtNXKUu+S5CjXslilYdKBAadzA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.4.tgz", + "integrity": "sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==", "cpu": [ "ppc64" ], @@ -2768,9 +2967,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.25.0.tgz", - "integrity": "sha512-G4hTREQrIdeV0PE2JruzI+vXdRnaK1pg64hemHq2v5fhv8C7WjVaeXc9P5i4Q5UC06d/L+zA0mszYIKl+wY8oA==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.4.tgz", + "integrity": "sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==", "cpu": [ "riscv64" ], @@ -2781,9 +2980,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.25.0.tgz", - "integrity": "sha512-9T/w0kQ+upxdkFL9zPVB6zy9vWW1deA3g8IauJxojN4bnz5FwSsUAD034KpXIVX5j5p/rn6XqumBMxfRkcHapQ==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.4.tgz", + "integrity": "sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==", "cpu": [ "s390x" ], @@ -2794,9 +2993,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.25.0.tgz", - "integrity": "sha512-ThcnU0EcMDn+J4B9LD++OgBYxZusuA7iemIIiz5yzEcFg04VZFzdFjuwPdlURmYPZw+fgVrFzj4CA64jSTG4Ig==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.4.tgz", + "integrity": "sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==", "cpu": [ "x64" ], @@ -2807,9 +3006,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.25.0.tgz", - "integrity": "sha512-zx71aY2oQxGxAT1JShfhNG79PnjYhMC6voAjzpu/xmMjDnKNf6Nl/xv7YaB/9SIa9jDYf8RBPWEnjcdlhlv1rQ==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.4.tgz", + "integrity": "sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==", "cpu": [ "x64" ], @@ -2820,9 +3019,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.25.0.tgz", - "integrity": "sha512-JT8tcjNocMs4CylWY/CxVLnv8e1lE7ff1fi6kbGocWwxDq9pj30IJ28Peb+Y8yiPNSF28oad42ApJB8oUkwGww==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.4.tgz", + "integrity": "sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==", "cpu": [ "arm64" ], @@ -2833,9 +3032,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.25.0.tgz", - "integrity": "sha512-dRLjLsO3dNOfSN6tjyVlG+Msm4IiZnGkuZ7G5NmpzwF9oOc582FZG05+UdfTbz5Jd4buK/wMb6UeHFhG18+OEg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.4.tgz", + "integrity": "sha512-KtwEJOaHAVJlxV92rNYiG9JQwQAdhBlrjNRp7P9L8Cb4Rer3in+0A+IPhJC9y68WAi9H0sX4AiG2NTsVlmqJeQ==", "cpu": [ "ia32" ], @@ -2846,9 +3045,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.25.0.tgz", - "integrity": "sha512-/RqrIFtLB926frMhZD0a5oDa4eFIbyNEwLLloMTEjmqfwZWXywwVVOVmwTsuyhC9HKkVEZcOOi+KV4U9wmOdlg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.4.tgz", + "integrity": "sha512-3j4jx1TppORdTAoBJRd+/wJRGCPC0ETWkXOecJ6PPZLj6SptXkrXcNqdj0oclbKML6FkQltdz7bBA3rUSirZug==", "cpu": [ "x64" ], @@ -3000,6 +3199,38 @@ "node": ">=10" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -3055,6 +3286,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -3079,6 +3316,12 @@ "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", "dev": true }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, "node_modules/@types/d3-scale": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", @@ -3116,9 +3359,9 @@ } }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", + "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", "dev": true, "dependencies": { "undici-types": "~6.19.8" @@ -3139,6 +3382,18 @@ "@types/node": "*" } }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "dev": true + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3149,6 +3404,210 @@ "@types/node": "*" } }, + "node_modules/@vitest/browser": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-2.1.5.tgz", + "integrity": "sha512-JrpnxvkrjlBrF7oXbK/YytWVYfJIzWYeDKppANlUaisBKwDso+yXlWocAJrANx8gUxyirF355Yx80S+SKQqayg==", + "dev": true, + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.5.2", + "@vitest/mocker": "2.1.5", + "@vitest/utils": "2.1.5", + "magic-string": "^0.30.12", + "msw": "^2.6.4", + "sirv": "^3.0.0", + "tinyrainbow": "^1.2.0", + "ws": "^8.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "2.1.5", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.5.tgz", + "integrity": "sha512-/RoopB7XGW7UEkUndRXF87A9CwkoZAJW01pj8/3pgmDVsjMH2IKy6H1A38po9tmUlwhSyYs0az82rbKd9Yaynw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.5", + "vitest": "2.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.5.tgz", + "integrity": "sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.5.tgz", + "integrity": "sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.5.tgz", + "integrity": "sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.5.tgz", + "integrity": "sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.5", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.5.tgz", + "integrity": "sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.5", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.5.tgz", + "integrity": "sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.5.tgz", + "integrity": "sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.5", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -3172,9 +3631,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -3276,6 +3735,33 @@ "string-width": "^4.1.0" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3285,6 +3771,21 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/apache-arrow": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.0.0.tgz", @@ -3340,6 +3841,15 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-back": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", @@ -3391,6 +3901,15 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -3528,76 +4047,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boxen/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/boxen/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/boxen/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/boxen/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/boxen/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/boxen/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/boxen/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3804,6 +4253,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "17.1.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.1.4.tgz", @@ -3972,37 +4430,23 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, - "node_modules/chalk-template": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", - "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", "dev": true, "dependencies": { - "chalk": "^4.1.2" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, - "node_modules/chalk-template/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/chalk-template/node_modules/chalk": { + "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -4018,43 +4462,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk-template/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "chalk": "^4.1.2" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/chalk-template/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/chalk-template/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/chalk-template/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" + "node": ">=12" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, "node_modules/charset": { @@ -4066,6 +4486,15 @@ "node": ">=4.0.0" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -4096,6 +4525,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4122,6 +4560,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -4290,6 +4746,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", @@ -4497,12 +4962,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4540,6 +5005,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4615,6 +5089,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4656,6 +5139,12 @@ "gl-matrix": "^3.3.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/domexception": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", @@ -4862,6 +5351,20 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4900,6 +5403,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -5092,6 +5601,15 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", @@ -5722,6 +6240,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -5743,6 +6270,44 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/happy-dom": { + "version": "15.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.6.tgz", + "integrity": "sha512-elX7iUTu+5+3b2+NGQc0L3eWyq9jKhuJJ4GpOMxxT/c2pg9O3L5H3ty2VECX0XXZgRmmRqXyOK8brA2hDI6LsQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "entities": "^4.5.0", + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/happy-dom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -5787,6 +6352,15 @@ "node": ">=0.10.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -5853,6 +6427,12 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true + }, "node_modules/headless": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/headless/-/headless-1.2.0.tgz", @@ -5892,6 +6472,12 @@ "whatwg-encoding": "^1.0.1" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/html-inject-script": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/html-inject-script/-/html-inject-script-2.0.0.tgz", @@ -6351,6 +6937,12 @@ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6393,6 +6985,83 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz", @@ -6595,6 +7264,12 @@ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "dev": true }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -6613,13 +7288,33 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.13", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.13.tgz", + "integrity": "sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" } }, "node_modules/make-dir": { @@ -7151,12 +7846,92 @@ "integrity": "sha512-3fQw+dAni/JJ4rkvMY7EZOz+tM+yuhrY3tKLJk74YOp/DQR0Ip+9yiKzZrC40uQ+Kin86s5TOjmL6UmxljOAfA==", "dev": true }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/msw": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.6.6.tgz", + "integrity": "sha512-npfIIVRHKQX3Lw4aLWX4wBh+lQwpqdZNyJYB5K/+ktK8NhtkdsTxGK7WDrgknozcVyRI7TOqY6yBS9j2FTR+YQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "chalk": "^4.1.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.27.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.1.tgz", + "integrity": "sha512-3Ta7CyV6daqpwuGJMJKABaUChZZejpzysZkQg1//bLRg2wKQ4duwsg3MMIsHuElq58iDqizg4DBUmK8H8wExJg==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -7569,6 +8344,12 @@ "integrity": "sha512-0IVH1dx0jn7yXAsGb9gXN/YeGgN0vInK8UjCG4r0p+WhkUpf63jvjQt2XNGLmcrkUfnLBb6CIvnXsY4Jh4Ytkw==", "dev": true }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -7602,6 +8383,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/pacote": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz", @@ -7728,6 +8515,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -7831,6 +8633,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", + "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", + "dev": true, + "dependencies": { + "playwright-core": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", + "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -7885,7 +8731,33 @@ "source-map-js": "^1.2.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/pretty-ms": { @@ -7984,6 +8856,12 @@ "node": ">=0.6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8040,6 +8918,12 @@ "integrity": "sha512-bHJul9CWcocrS+w5e5QrKYXV9NkbSA9hxSEyhYuctwm6keY9NXR2Xt/4A0vbMP0QvuwyfEyb4bkowYXv1ziEbg==", "dev": true }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, "node_modules/read-package-json": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.4.tgz", @@ -8252,9 +9136,9 @@ } }, "node_modules/regl": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.0.tgz", - "integrity": "sha512-oWUce/aVoEvW5l2V0LK7O5KJMzUSKeiOwFuJehzpSFd43dO5spP9r+sSUfhKtsky4u6MCqWJaRL+abzExynfTg==" + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==" }, "node_modules/regl-line": { "version": "1.1.1", @@ -8351,6 +9235,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -8461,9 +9351,9 @@ } }, "node_modules/rollup": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.25.0.tgz", - "integrity": "sha512-uVbClXmR6wvx5R1M3Od4utyLUxrmOcEm3pAtMphn73Apq19PDtHpgZoEvqH2YnnaNUuvKmg2DgRd2Sqv+odyqg==", + "version": "4.27.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.4.tgz", + "integrity": "sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==", "dev": true, "dependencies": { "@types/estree": "1.0.6" @@ -8476,24 +9366,24 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.25.0", - "@rollup/rollup-android-arm64": "4.25.0", - "@rollup/rollup-darwin-arm64": "4.25.0", - "@rollup/rollup-darwin-x64": "4.25.0", - "@rollup/rollup-freebsd-arm64": "4.25.0", - "@rollup/rollup-freebsd-x64": "4.25.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.25.0", - "@rollup/rollup-linux-arm-musleabihf": "4.25.0", - "@rollup/rollup-linux-arm64-gnu": "4.25.0", - "@rollup/rollup-linux-arm64-musl": "4.25.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.25.0", - "@rollup/rollup-linux-riscv64-gnu": "4.25.0", - "@rollup/rollup-linux-s390x-gnu": "4.25.0", - "@rollup/rollup-linux-x64-gnu": "4.25.0", - "@rollup/rollup-linux-x64-musl": "4.25.0", - "@rollup/rollup-win32-arm64-msvc": "4.25.0", - "@rollup/rollup-win32-ia32-msvc": "4.25.0", - "@rollup/rollup-win32-x64-msvc": "4.25.0", + "@rollup/rollup-android-arm-eabi": "4.27.4", + "@rollup/rollup-android-arm64": "4.27.4", + "@rollup/rollup-darwin-arm64": "4.27.4", + "@rollup/rollup-darwin-x64": "4.27.4", + "@rollup/rollup-freebsd-arm64": "4.27.4", + "@rollup/rollup-freebsd-x64": "4.27.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.27.4", + "@rollup/rollup-linux-arm-musleabihf": "4.27.4", + "@rollup/rollup-linux-arm64-gnu": "4.27.4", + "@rollup/rollup-linux-arm64-musl": "4.27.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.27.4", + "@rollup/rollup-linux-riscv64-gnu": "4.27.4", + "@rollup/rollup-linux-s390x-gnu": "4.27.4", + "@rollup/rollup-linux-x64-gnu": "4.27.4", + "@rollup/rollup-linux-x64-musl": "4.27.4", + "@rollup/rollup-win32-arm64-msvc": "4.27.4", + "@rollup/rollup-win32-ia32-msvc": "4.27.4", + "@rollup/rollup-win32-x64-msvc": "4.27.4", "fsevents": "~2.3.2" } }, @@ -8687,6 +9577,12 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8773,6 +9669,20 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sirv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8955,6 +9865,27 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true + }, "node_modules/stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -9062,6 +9993,12 @@ "xtend": ">=4.0.0 <4.1.0-0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9161,6 +10098,18 @@ "node": ">= 8.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -9486,6 +10435,73 @@ "source-map": "^0.6.0" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -9554,6 +10570,45 @@ "integrity": "sha512-nO0WWuIDTde3CWK/8IPpG50dyhUilgpsqzYSIP+w20Yh+4iDgb/2Gs75QItcp0Hmx/JtxtTXBalj+LSTD1VemA==", "dev": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9566,6 +10621,15 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -9768,9 +10832,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -9925,6 +10989,16 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/utf8-stream": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/utf8-stream/-/utf8-stream-0.0.0.tgz", @@ -10060,6 +11134,28 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.5.tgz", + "integrity": "sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-virtual-html-template": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/vite-plugin-virtual-html-template/-/vite-plugin-virtual-html-template-1.1.0.tgz", @@ -10069,6 +11165,71 @@ "lodash": "^4.17.21" } }, + "node_modules/vitest": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.5.tgz", + "integrity": "sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.5", + "@vitest/mocker": "2.1.5", + "@vitest/pretty-format": "^2.1.5", + "@vitest/runner": "2.1.5", + "@vitest/snapshot": "2.1.5", + "@vitest/spy": "2.1.5", + "@vitest/utils": "2.1.5", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.5", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.5", + "@vitest/ui": "2.1.5", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -10149,6 +11310,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -10235,72 +11412,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10417,6 +11528,18 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zora": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/zora/-/zora-4.1.0.tgz", diff --git a/package.json b/package.json index 572f08c..bf734ff 100644 --- a/package.json +++ b/package.json @@ -32,20 +32,20 @@ "pretest": "npm run lint", "prestart": "cp scripts/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit && echo 'pre-commit hook copied'", "start": "vite --port=3000", - "test": "rollup -c ./rollup.test.config.mjs | tape-run --render='tap-spec'", - "watch": "rollup -cw" + "test": "vitest run", + "coverage": "vitest run --coverage" }, "dependencies": { "@flekschas/utils": "^0.32.2", "dom-2d-camera": "~2.2.5", "gl-matrix": "~3.4.3", "pub-sub-es": "~3.0.0", - "regl": "~2.1.0", + "regl": "~2.1.1", "regl-line": "~1.1.1" }, "peerDependencies": { "pub-sub-es": "~3.0.0", - "regl": "~2.1.0" + "regl": "~2.1.1" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -59,7 +59,9 @@ "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.3", "@types/d3-scale": "^4.0.6", - "@types/node": "^22.9.0", + "@types/node": "^22.9.3", + "@vitest/browser": "^2.1.5", + "@vitest/coverage-v8": "^2.1.5", "apache-arrow": "^18.0.0", "browser-env": "^3.3.0", "d3-axis": "^3.0.0", @@ -69,14 +71,13 @@ "esm": "^3.2.25", "gh-pages": "^6.2.0", "merge": "^2.1.1", - "rollup": "^4.25.0", + "playwright": "^1.49.0", + "rollup": "^4.27.4", "rollup-plugin-filesize": "^10.0.0", - "tap-spec": "^5.0.0", - "tape-run": "^11.0.0", - "typescript": "~5.6.3", + "typescript": "~5.7.2", "vite": "^5.4.11", "vite-plugin-virtual-html-template": "^1.1.0", - "zora": "^4.1.0" + "vitest": "^2.1.5" }, "engines": { "npm": ">=7.0.0", diff --git a/tests/assets/image.jpg b/tests/assets/image.jpg new file mode 100644 index 0000000..bc304e8 Binary files /dev/null and b/tests/assets/image.jpg differ diff --git a/tests/constructor.test.js b/tests/constructor.test.js new file mode 100644 index 0000000..b39d004 --- /dev/null +++ b/tests/constructor.test.js @@ -0,0 +1,195 @@ +import '@babel/polyfill'; +import { assert, expect, test } from 'vitest'; +import { isFunction } from '@flekschas/utils'; + +import createScatterplot, { + createRegl, + createRenderer, + createSpatialIndex, + createTextureFromUrl, +} from '../src'; + +import { + DEFAULT_COLOR_NORMAL, + DEFAULT_COLOR_ACTIVE, + DEFAULT_COLOR_HOVER, + DEFAULT_COLOR_BG, + DEFAULT_HEIGHT, + DEFAULT_POINT_OUTLINE_WIDTH, + DEFAULT_POINT_SIZE, + DEFAULT_POINT_SIZE_SELECTED, + DEFAULT_OPACITY_INACTIVE_MAX, + DEFAULT_OPACITY_INACTIVE_SCALE, + DEFAULT_WIDTH, + DEFAULT_GAMMA, + DEFAULT_OPACITY, + IMAGE_LOAD_ERROR, +} from '../src/constants'; + +import { + createCanvas, + flatArrayEqual, +} from './utils'; + +import imageUrl from './assets/image.jpg'; + +const EPS = 1e-7; + +const floatEqual = (a, b) => Math.abs(a - b) <= EPS; + +test('createRegl()', () => { + const dim = 200; + const canvas = createCanvas(dim, dim); + const gl = canvas.getContext('webgl'); + + expect(gl.drawingBufferWidth).toBe(dim); + expect(gl.drawingBufferHeight).toBe(dim); + + const regl = createRegl(canvas); + + expect(regl).toBeDefined(); + + regl.destroy(); +}); + +test('createScatterplot()', () => { + const canvas = createCanvas(null, null); + const scatterplot = createScatterplot({ canvas }); + + expect(scatterplot.get('canvas')).toBe(canvas); + expect(scatterplot.get('backgroundColor')).toBe(DEFAULT_COLOR_BG); + expect(scatterplot.get('pointColor')).toBe(DEFAULT_COLOR_NORMAL); + expect(scatterplot.get('pointColorActive')).toBe(DEFAULT_COLOR_ACTIVE); + expect(scatterplot.get('pointColorHover')).toBe(DEFAULT_COLOR_HOVER); + expect(scatterplot.get('pointSize')).toBe(DEFAULT_POINT_SIZE); + expect(scatterplot.get('pointSizeSelected')).toBe(DEFAULT_POINT_SIZE_SELECTED); + expect(scatterplot.get('pointOutlineWidth')).toBe(DEFAULT_POINT_OUTLINE_WIDTH); + expect(scatterplot.get('opacity')).toBe(DEFAULT_OPACITY); + expect(scatterplot.get('opacityInactiveMax')).toBe(DEFAULT_OPACITY_INACTIVE_MAX); + expect(scatterplot.get('opacityInactiveScale')).toBe(DEFAULT_OPACITY_INACTIVE_SCALE); + expect(scatterplot.get('width')).toBe(DEFAULT_WIDTH); + expect(scatterplot.get('height')).toBe(DEFAULT_HEIGHT); + expect(scatterplot.get('opacityInactiveMax')).toBe(DEFAULT_OPACITY_INACTIVE_MAX); + expect(scatterplot.get('opacityInactiveScale')).toBe(DEFAULT_OPACITY_INACTIVE_SCALE); + expect(scatterplot.get('width')).toBe(DEFAULT_WIDTH); + expect(scatterplot.get('height')).toBe(DEFAULT_HEIGHT); + + scatterplot.destroy(); +}); + +test('createScatterplot({ cameraTarget, cameraDistance, cameraRotation, cameraView })', () => { + const cameraTarget = [1, 1]; + const cameraDistance = 2; + const cameraRotation = Math.PI / 4; + const scatterplot = createScatterplot({ + cameraTarget, + cameraDistance, + cameraRotation, + }); + + expect(flatArrayEqual([1, 1], scatterplot.get('cameraTarget'), floatEqual)).toBe(true); + expect(floatEqual(cameraDistance, scatterplot.get('cameraDistance'))).toBe(true); + expect(floatEqual(cameraRotation, scatterplot.get('cameraRotation'))).toBe(true); + + // biome-ignore format: the array should not be formatted + const cameraView = new Float32Array([ + 0.5, 0, 0, 0.5, + 0, 0.5, 0, 0.5, + 0, 0, 0.5, 0, + 0, 0, 0, 1, + ]); + const scatterplot2 = createScatterplot({ cameraView }); + + expect(cameraView).toEqual(scatterplot2.get('cameraView')); + + scatterplot.destroy(); + scatterplot2.destroy(); +}); + +test('createTextureFromUrl()', async ({ skip }) => { + const regl = createRegl(createCanvas()); + + try { + const texture = await createTextureFromUrl(regl, imageUrl); + + expect(texture._reglType).toBe('texture2d'); + } catch (e) { + if (e.message === IMAGE_LOAD_ERROR) { + skip('Skipping because image loading timed out'); + } else { + assert.fail('Failed to load image from URL'); + } + } + + regl.destroy(); +}); + +test('createRenderer()', () => { + const canvas = createCanvas(); + const regl = createRegl(canvas); + const renderer = createRenderer({ canvas, regl }); + + expect(!!renderer).toBe(true); + expect(renderer.canvas).toBe(canvas); + expect(renderer.regl).toBe(regl); + expect(renderer.gamma).toBe(DEFAULT_GAMMA); + expect(isFunction(renderer.render)).toBe(true); + expect(isFunction(renderer.onFrame)).toBe(true); + expect(isFunction(renderer.refresh)).toBe(true); + expect(isFunction(renderer.destroy)).toBe(true); + + const sp1 = createScatterplot({ renderer }); + const sp2 = createScatterplot({ renderer }); + + expect(sp1.get('renderer')).toBe(renderer); + expect(sp2.get('renderer')).toBe(renderer); + + sp1.destroy(); + sp2.destroy(); + + // Renderer should have not been destroyed + expect(renderer.canvas).toBe(canvas); + expect(renderer.regl).toBe(regl); + + const sp3 = createScatterplot({ renderer }); + expect(sp3.get('renderer')).toBe(renderer); + + renderer.gamma = 10; + expect(renderer.gamma).toBe(10); + + sp3.destroy(); + renderer.destroy(); + + // Now the renderer should have been destroyed + expect(renderer.canvas).toBeUndefined(); + expect(renderer.regl).toBeUndefined(); +}); + +test('createSpatialIndex', async () => { + const points = { + x: [-1, 1, 0, -1, 1], + y: [1, 1, 0, -1, -1], + z: [0.2, 0.4, 0.6, 0.8, 1], + }; + + const spatialIndex1 = await createSpatialIndex(points); + const spatialIndex2 = await createSpatialIndex(points, { + useWorker: true, + }); + + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + await scatterplot.draw(points, { spatialIndex: spatialIndex1 }); + + await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); + + expect(scatterplot.get('pointsInView')).toEqual([1, 2]); + + await scatterplot.draw(points, { spatialIndex: spatialIndex2 }); + + await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); + + expect(scatterplot.get('pointsInView')).toEqual([2, 3]); + + scatterplot.destroy(); +}); diff --git a/tests/events.test.js b/tests/events.test.js new file mode 100644 index 0000000..0e20c67 --- /dev/null +++ b/tests/events.test.js @@ -0,0 +1,843 @@ +import '@babel/polyfill'; +import { assert, expect, test } from 'vitest'; +import { scaleLinear } from 'd3-scale'; +import { mat4 } from 'gl-matrix'; +import { nextAnimationFrame } from '@flekschas/utils'; + +import createScatterplot from '../src'; + +import { + DEFAULT_LASSO_MIN_DELAY, + DEFAULT_LASSO_MIN_DIST, + KEY_ACTION_LASSO, + KEY_ACTION_ROTATE, + SINGLE_CLICK_DELAY, + CONTINUOUS, + CATEGORICAL, +} from '../src/constants'; + +import { + asyncForEach, + createCanvas, + createMouseEvent, + createKeyboardEvent, + wait, + capitalize, + catchError, +} from './utils'; + +test('init and destroy events', async () => { + const canvas = createCanvas(200, 200); + const scatterplot = createScatterplot({ + canvas, + width: 200, + height: 200, + }); + + const whenInit = new Promise((resolve) => { + scatterplot.subscribe('init', resolve, 1); + }); + const whenDestroy = new Promise((resolve) => { + scatterplot.subscribe('destroy', resolve, 1); + }); + + await whenInit; + + scatterplot.destroy(); + + await whenDestroy; + + expect(true).toBe(true); +}); + +test('throw an error when calling draw() after destroy()', async () => { + const canvas = createCanvas(200, 200); + const scatterplot = createScatterplot({ + canvas, + width: 200, + height: 200, + }); + + await scatterplot.draw([[0, 0]]); + + scatterplot.destroy(); + + try { + await scatterplot.draw([[0, 0]]); + assert.fail( + 'should have thrown an error because the scatterplot instance is destroyed' + ); + } catch (e) { + expect(e.message).toBe('The instance was already destroyed'); + } +}); + +test('test that `isPointsDrawn` is set correctly', async () => { + const canvas = createCanvas(200, 200); + const scatterplot = createScatterplot({ + canvas, + width: 200, + height: 200, + }); + + expect(scatterplot.get('isPointsDrawn')).toBe(false); + expect(scatterplot.get('isDestroyed')).toBe(false); + + await scatterplot.draw([[0, 0]]); + + expect(scatterplot.get('isPointsDrawn')).toBe(true); + + scatterplot.destroy(); + + expect(scatterplot.get('isDestroyed')).toBe(true); + expect(scatterplot.get('isPointsDrawn')).toBe(false); +}); + +test('do _not_ throw an error when calling draw() _before_ destroy()', async () => { + const canvas = createCanvas(200, 200); + const scatterplot = createScatterplot({ + canvas, + width: 200, + height: 200, + }); + + let numDraws = 0; + scatterplot.subscribe('draw', () => ++numDraws); + + scatterplot.draw([[0, 0]]); + scatterplot.destroy(); + + // draw call should have been canceled due to the destroy call + expect(numDraws).toBe(0); +}); + +test('test that draw() recognized the correct data type of z and w', async () => { + const canvas = createCanvas(200, 200); + const scatterplot = createScatterplot({ + canvas, + width: 200, + height: 200, + }); + + await scatterplot.draw([[0, 0]]); + + expect(scatterplot.get('zDataType')).toBe(CATEGORICAL); + expect(scatterplot.get('wDataType')).toBe(CATEGORICAL); + + await scatterplot.draw([[0, 0, 1, 1]]); + + expect(scatterplot.get('zDataType')).toBe(CATEGORICAL); + expect(scatterplot.get('wDataType')).toBe(CATEGORICAL); + + await scatterplot.draw([ + [0, 0, 0, 0], + [0, 0, 1, 1], + ]); + + expect(scatterplot.get('zDataType')).toBe(CATEGORICAL); + expect(scatterplot.get('wDataType')).toBe(CATEGORICAL); + + await scatterplot.draw([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 10, 10], + ]); + + expect(scatterplot.get('zDataType')).toBe(CATEGORICAL); + expect(scatterplot.get('wDataType')).toBe(CATEGORICAL); + + await scatterplot.draw([[0, 0, 0.5, 0.5]]); + + expect(scatterplot.get('zDataType')).toBe(CONTINUOUS); + expect(scatterplot.get('wDataType')).toBe(CONTINUOUS); + + await scatterplot.draw([ + [0, 0, 0, 0], + [0, 0, 0.5, 0.5], + [0, 0, 1, 1], + ]); + + expect(scatterplot.get('zDataType')).toBe(CONTINUOUS); + expect(scatterplot.get('wDataType')).toBe(CONTINUOUS); + + await scatterplot.draw( + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + ], + { zDataType: CONTINUOUS, wDataType: CONTINUOUS } + ); + + expect(scatterplot.get('zDataType')).toBe(CONTINUOUS); + expect(scatterplot.get('wDataType')).toBe(CONTINUOUS); + + await scatterplot.draw([[0, 0, 0.5, 1]]); + + expect(scatterplot.get('zDataType')).toBe(CONTINUOUS); + expect(scatterplot.get('wDataType')).toBe(CATEGORICAL); + + await scatterplot.draw([[0, 0, 1, 0.5]]); + + expect(scatterplot.get('zDataType')).toBe(CATEGORICAL); + expect(scatterplot.get('wDataType')).toBe(CONTINUOUS); + + scatterplot.destroy(); +}); + +test('test drawing point connections via `showLineConnections`', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(200, 200), + width: 200, + height: 200, + showPointConnections: true, + }); + + let numConnectionsDraws = 0; + scatterplot.subscribe('pointConnectionsDraw', () => { + ++numConnectionsDraws; + }); + + await scatterplot.draw( + new Array(10) + .fill() + .map((_, i) => [ + -1 + (i / 6) * 2, // x + -1 + Math.random() * 2, // y + i, // category + 1, // group + 0, // line group + ]) + ); + await wait(0); + + expect(numConnectionsDraws).toBe(1); + + await scatterplot.draw( + new Array(10) + .fill() + .map((e, i) => [ + -1 + (i / 6) * 2, + -1 + Math.random() * 2, + i, + 1, + i % 5, + ]) + ); + await wait(0); + + expect(numConnectionsDraws).toBe(2); + + scatterplot.destroy(); +}); + +test('draw(), clear(), publish("select")', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + await scatterplot.draw(points); + // The second draw call should not block the drawing of the points! + // This test is related to a previous issue caused by `drawRaf` as `withRaf` + // overwrites previous arguments. While that is normally expected, this + // should not overwrite the points from above + await scatterplot.draw(); + + let selectedPoints = []; + const selectHandler = ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }; + const deselectHandler = () => { + selectedPoints = []; + }; + // Event names should be case insensitive. Let's test it + scatterplot.subscribe('sElEcT', selectHandler); + scatterplot.subscribe('deselect', deselectHandler); + + // Test single selection via mouse clicks + canvas.dispatchEvent( + createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) + ); + canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); + + await wait(0); + + expect(selectedPoints.length).toBe(1); + expect(selectedPoints[0]).toBe(0); + + // Test deselection + canvas.dispatchEvent(createMouseEvent('dblclick', hdim, hdim)); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + // Test that mousedown + mousemove + click is not interpreted as a click when + // the cursor moved more than `DEFAULT_LASSO_MIN_DIST` in between mousedown and + // mouseup + canvas.dispatchEvent( + createMouseEvent('mousedown', hdim - DEFAULT_LASSO_MIN_DIST, hdim, { + buttons: 1, + }) + ); + canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + // Test that clearing the points works. The selection that worked previously + // should not work anymore + scatterplot.clear(); + window.dispatchEvent( + createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) + ); + canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + scatterplot.destroy(); +}); + +test( + 'lasso selection (with events: select, lassoStart, lassoExtend, and lassoEnd)', + async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + await scatterplot.draw(points); + + let selectedPoints = []; + const selectHandler = ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }; + const deselectHandler = () => { + selectedPoints = []; + }; + scatterplot.subscribe('select', selectHandler); + scatterplot.subscribe('deselect', deselectHandler); + + let lassoStartCount = 0; + let lassoExtendCount = 0; + let lassoEndCount = 0; + let lassoEndCoordinates = []; + scatterplot.subscribe('lassoStart', () => ++lassoStartCount); + scatterplot.subscribe('lassoExtend', () => ++lassoExtendCount); + scatterplot.subscribe('lassoEnd', ({ coordinates }) => { + ++lassoEndCount; + lassoEndCoordinates = coordinates; + }); + + const [lassoKey] = Object.entries(scatterplot.get('keyMap')).find( + (mapping) => mapping[1] === KEY_ACTION_LASSO + ); + + // Test multi selections via mousedown + mousemove + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 1.125, hdim, { + [`${lassoKey}Key`]: true, + buttons: 1, + }) + ); + + // Needed to first digest the mousedown event + await wait(0); + + const mousePositions = [ + [dim * 1.125, hdim], + [hdim, -dim * 0.125], + [-dim * 0.125, -dim * 0.125], + [-dim * 0.125, dim * 0.125], + [0, dim * 0.9], + [dim * 0.1, dim * 0.9], + [dim * 0.1, dim * 1.125], + [dim * 1.125, dim * 1.125], + ]; + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(selectedPoints.length).toBe(3); + expect(selectedPoints).toEqual([0, 2, 4]); + + expect(lassoStartCount).toBe(1); + expect(lassoExtendCount).toBe(mousePositions.length); + expect(lassoEndCoordinates.length).toBe(mousePositions.length); + expect(lassoEndCount).toBe(1); + + scatterplot.destroy(); + } +); + +test('disable lasso selection', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + keyMap: {}, + }); + + await scatterplot.draw([[0, 0]]); + + let selectedPoints = []; + scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }); + + let lassoStartCount = 0; + scatterplot.subscribe('lassoStart', () => ++lassoStartCount); + + expect(Object.entries(scatterplot.get('keyMap')).length).toBe(0); + + // Test multi selections via mousedown + mousemove + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 1.125, hdim, { + buttons: 1, + altKey: true, + ctrlKey: true, + metaKey: true, + shiftKey: true, + }) + ); + + // Needed to first digest the mousedown event + await wait(0); + + const mousePositions = [ + [dim * 1.125, hdim], + [hdim, -dim * 0.125], + [-dim * 0.125, -dim * 0.125], + [-dim * 0.125, dim * 0.125], + [0, dim * 0.9], + [dim * 0.1, dim * 0.9], + [dim * 0.1, dim * 1.125], + [dim * 1.125, dim * 1.125], + ]; + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(lassoStartCount).toBe(0); + expect(selectedPoints.length).toBe(0); + + scatterplot.destroy(); +}); + +test('test lasso selection via the initiator', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + lassoInitiator: false, + }); + + await scatterplot.draw([ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]); + + let selectedPoints = []; + scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }); + + const lassoIniatorElement = scatterplot.get('lassoInitiatorElement'); + + expect(scatterplot.get('lassoInitiator')).toBe(false); + expect(lassoIniatorElement.id.startsWith('lasso-initiator')).toBe(true); + expect(scatterplot.get('lassoInitiatorParentElement')).toBe(document.body); + + canvas.dispatchEvent(createMouseEvent('click', dim * 1.125, hdim)); + + // We need to wait for the click delay and some extra milliseconds for + // the circle to appear + await wait(SINGLE_CLICK_DELAY + 50); + + lassoIniatorElement.dispatchEvent( + createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) + ); + await wait(0); + + const mousePositions = [ + [dim * 1.125, hdim], + [hdim, -dim * 0.125], + [-dim * 0.125, -dim * 0.125], + [-dim * 0.125, dim * 0.125], + [0, dim * 0.9], + [dim * 0.1, dim * 0.9], + [dim * 0.1, dim * 1.125], + [dim * 1.125, dim * 1.125], + ]; + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + scatterplot.set({ lassoInitiator: true }); + + await wait(0); + + expect(scatterplot.get('lassoInitiator')).toBe(true); + + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) + ); + await wait(0); + canvas.dispatchEvent(createMouseEvent('click', dim * 1.125, hdim)); + + // We need to wait for the click delay and some extra milliseconds for + // the circle to appear + await wait(SINGLE_CLICK_DELAY + 50); + + lassoIniatorElement.dispatchEvent( + createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) + ); + await wait(0); + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 4]); + + scatterplot.destroy(); +}); + +test('test rotation', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + }); + + await scatterplot.draw([[0, 0]]); + + const initialRotation = scatterplot.get('cameraRotation'); + expect(initialRotation).toBe(0); + + let rotation; + const viewHandler = ({ camera }) => { + rotation = camera.rotation; + }; + scatterplot.subscribe('view', viewHandler); + + let [rotateKey] = Object.entries(scatterplot.get('keyMap')).find( + (mapping) => mapping[1] === KEY_ACTION_ROTATE + ); + + // Test rotation via keydown + mousedown + mousemove + keydown + window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent( + createKeyboardEvent('keydown', capitalize(rotateKey), { + [`${rotateKey}Key`]: true, + }) + ); + await wait(0); + + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 0.75, hdim, { + [`${rotateKey}Key`]: true, + buttons: 1, + }) + ); + + await wait(0); + + const mousePositions = [ + [dim * 0.75, hdim], + [dim * 0.75, hdim * 0.5], + ]; + + let whenDrawn = new Promise((resolve) => { + scatterplot.subscribe('draw', resolve, 1); + }); + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + // We need to ensure that the camera's tick() function was called before + // we release the mouse via the mouseup event + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent(createMouseEvent('mouseup')); + window.dispatchEvent( + createKeyboardEvent('keyup', capitalize(rotateKey), { + [`${rotateKey}Key`]: true, + }) + ); + + await whenDrawn; + await wait(10); + + expect(initialRotation !== rotation && Number.isFinite(rotation)).toBe(true); + + const lastRotation = rotation; + const oldRotateKey = rotateKey; + + rotateKey = 'shift'; + scatterplot.set({ keyMap: { [rotateKey]: 'rotate' } }); + + // Needed to first digest the keyMap change + await wait(10); + + // Test rotation via mousedown + mousemove + keydown + window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent( + createKeyboardEvent('keydown', capitalize(oldRotateKey), { + [`${oldRotateKey}Key`]: true, + }) + ); + await wait(0); + + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 0.75, hdim, { + [`${oldRotateKey}Key`]: true, + buttons: 1, + }) + ); + + // Needed to first digest the mousedown event + await wait(10); + + whenDrawn = new Promise((resolve) => { + scatterplot.subscribe('draw', resolve, 1); + }); + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + // We need to ensure that the camera's tick() function was called before + // we release the mouse via the mouseup event + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent(createMouseEvent('mouseup')); + window.dispatchEvent( + createKeyboardEvent('keyup', capitalize(oldRotateKey), { + [`${oldRotateKey}Key`]: true, + }) + ); + + await whenDrawn; + await wait(10); + + expect(lastRotation).toBe(rotation); + + // Test rotation via mousedown + mousemove + keydown + window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent( + createKeyboardEvent('keydown', capitalize(rotateKey), { + [`${rotateKey}Key`]: true, + }) + ); + await wait(0); + + canvas.dispatchEvent( + createMouseEvent('mousedown', dim * 0.75, hdim, { + [`${rotateKey}Key`]: true, + buttons: 1, + }) + ); + + // Needed to first digest the mousedown event + await wait(10); + + whenDrawn = new Promise((resolve) => { + scatterplot.subscribe('draw', resolve, 1); + }); + + await asyncForEach(mousePositions, async (mousePosition) => { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + }); + + // We need to ensure that the camera's tick() function was called before + // we release the mouse via the mouseup event + await nextAnimationFrame(); + await wait(0); + + window.dispatchEvent(createMouseEvent('mouseup')); + window.dispatchEvent( + createKeyboardEvent('keyup', capitalize(rotateKey), { + [`${rotateKey}Key`]: true, + }) + ); + + await whenDrawn; + await wait(10); + + expect(lastRotation !== rotation).toBe(true); + + scatterplot.destroy(); +}); + +test('point hover with publish("pointover") and publish("pointout")', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + await scatterplot.draw(points); + + let hoveredPoint = null; + const pointoverHandler = (point) => { + hoveredPoint = point; + }; + const pointoutHandler = () => { + hoveredPoint = null; + }; + scatterplot.subscribe('pointover', pointoverHandler); + scatterplot.subscribe('pointout', pointoutHandler); + + // Test single selection via mouse clicks + canvas.dispatchEvent(createMouseEvent('mouseenter', hdim, hdim)); + await wait(250); + window.dispatchEvent(createMouseEvent('mousemove', hdim, hdim)); + await wait(250); + + expect(hoveredPoint).toBe(0); + + // Test deselection + window.dispatchEvent(createMouseEvent('mousemove', hdim / 2, hdim)); + + await wait(0); + + expect(hoveredPoint).toBe(null); + + scatterplot.destroy(); +}); + +test('publish("view")', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const xScale = scaleLinear().domain([-5, 5]); + const yScale = scaleLinear().domain([0, 0.5]); + + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + xScale, + yScale, + }); + await scatterplot.draw([[0, 0]]); + + const predictedView = mat4.fromTranslation([], [-1, 0, 0]); + + let currentView; + let currentCamera; + const viewHandler = ({ camera, view }) => { + currentCamera = camera; + currentView = view; + }; + scatterplot.subscribe('view', viewHandler); + + window.dispatchEvent(createMouseEvent('mouseup', hdim, hdim)); + window.dispatchEvent(createMouseEvent('mousemove', hdim, hdim)); + await nextAnimationFrame(); + await wait(50); + + canvas.dispatchEvent( + createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) + ); + await nextAnimationFrame(); + await wait(50); + window.dispatchEvent(createMouseEvent('mousemove', 0, hdim)); + await nextAnimationFrame(); + await wait(50); + + expect(Array.from(currentView)).toEqual(predictedView); + expect(currentCamera).toBeDefined(); + expect(xScale.domain()).toEqual([0, 10]); + expect(yScale.domain()).toEqual([0, 0.5]); + + scatterplot.destroy(); +}); diff --git a/tests/get-set.test.js b/tests/get-set.test.js new file mode 100644 index 0000000..0cf6073 --- /dev/null +++ b/tests/get-set.test.js @@ -0,0 +1,632 @@ +import '@babel/polyfill'; +import { assert, expect, test } from 'vitest'; + +import { version } from '../package.json'; + +import createScatterplot, { + createRegl, + createRenderer, + createTextureFromUrl, +} from '../src'; + +import { + DEFAULT_LASSO_COLOR, + DEFAULT_SHOW_RETICLE, + DEFAULT_RETICLE_COLOR, + DEFAULT_LASSO_MIN_DELAY, + DEFAULT_LASSO_MIN_DIST, + DEFAULT_LASSO_CLEAR_EVENT, + DEFAULT_POINT_CONNECTION_OPACITY, + DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, + DEFAULT_POINT_CONNECTION_SIZE, + DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, + DEFAULT_IMAGE_LOAD_TIMEOUT, + IMAGE_LOAD_ERROR, +} from '../src/constants'; + +import { + createCanvas, + flatArrayEqual, +} from './utils'; + +import imageUrl from './assets/image.jpg'; + +const EPS = 1e-7; + +const floatEqual = (a, b) => Math.abs(a - b) <= EPS; + +const valueVariants = { + valueZ: ['category', 'value1', 'valueA', 'valueZ', 'z'], + valueW: ['value', 'value2', 'valueB', 'valueW', 'w'], +}; + +test('get("canvas"), get("regl"), and get("version")', () => { + const canvas = createCanvas(); + const regl = createRegl(canvas); + const renderer = createRenderer({ regl }); + const scatterplot = createScatterplot({ canvas, renderer }); + + expect(scatterplot.get('canvas')).toBe(canvas); + expect(scatterplot.get('regl')).toBe(regl); + expect(scatterplot.get('version')).toBe(version); + + scatterplot.destroy(); +}); + +test('set({ width, height })', () => { + const w1 = 200; + const h1 = 200; + + const canvas = createCanvas(w1, h1); + const gl = canvas.getContext('webgl'); + const scatterplot = createScatterplot({ canvas, width: w1, height: h1 }); + + expect(gl.drawingBufferWidth).toBe(w1 * window.devicePixelRatio); + expect(gl.drawingBufferHeight).toBe(h1 * window.devicePixelRatio); + + const w2 = 400; + const h2 = 300; + + scatterplot.set({ width: w2, height: h2 }); + + expect(scatterplot.get('width')).toBe(w2); + expect(scatterplot.get('height')).toBe(h2); + + expect(gl.drawingBufferWidth).toBe(w2 * window.devicePixelRatio); + expect(gl.drawingBufferHeight).toBe(h2 * window.devicePixelRatio); + + scatterplot.destroy(); +}); + +test('set({ aspectRatio })', (t) => { + const canvas = createCanvas(400, 200); + const scatterplot = createScatterplot({ + canvas, + width: 400, + height: 200, + }); + + const aspectRatio = 2; + scatterplot.set({ aspectRatio }); + + expect(scatterplot.get('aspectRatio')).toBe(aspectRatio); + + scatterplot.destroy(); +}); + +test('set({ backgroundColor })', () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const backgroundHex = '#ff0000'; + const backgroundNrgba = [1, 0, 0, 1]; + scatterplot.set({ backgroundColor: backgroundHex }); + + expect( + scatterplot + .get('backgroundColor') + .every((v, i) => v === backgroundNrgba[i]) + ).toBe(true); + + scatterplot.destroy(); +}); + +test('set({ backgroundImage })', async ({ skip }) => { + const canvas = createCanvas(); + const regl = createRegl(canvas); + const scatterplot = createScatterplot({ canvas, regl }); + + try { + const backgroundImage = await createTextureFromUrl(regl, imageUrl); + + scatterplot.set({ backgroundImage }); + + expect(scatterplot.get('backgroundImage')).toBe(backgroundImage); + } catch (e) { + if (e.message === IMAGE_LOAD_ERROR) { + skip(`Failed to load image from URL: ${e.message}`); + } else { + assert.fail('Could not create background image from URL'); + } + } + + try { + const backgroundImage = await scatterplot.createTextureFromUrl( + 'https://picsum.photos/300/200/' + ); + + scatterplot.set({ backgroundImage }); + + expect(scatterplot.get('backgroundImage')).toBe(backgroundImage); + + scatterplot.set({ backgroundImage: null }); + + expect(scatterplot.get('backgroundImage')).toBe(null); + } catch (e) { + if (e.message === IMAGE_LOAD_ERROR) { + skip(`Failed to load image from URL: ${e.message}`); + } else { + assert.fail('Could not create background image from URL'); + } + } + + try { + await new Promise((resolve, reject) => { + scatterplot.subscribe('backgroundImageReady', resolve, 1); + scatterplot.set({ + backgroundImage: 'https://picsum.photos/300/200/', + }); + setTimeout(() => { + reject(new Error(IMAGE_LOAD_ERROR)); + }, DEFAULT_IMAGE_LOAD_TIMEOUT); + }); + + expect(scatterplot.get('backgroundImage').width).toBe(300); + } catch (e) { + if (e.message === IMAGE_LOAD_ERROR) { + skip(`Failed to load image from URL: ${e.message}`); + } else { + assert.fail('Could not create background image from URL'); + } + } + + // Base64 image + scatterplot.set({ + backgroundImage: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QDeRXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAAA4YwAA6AMAADhjAADoAwAABwAAkAcABAAAADAyMTABkQcABAAAAAECAwCGkgcAFQAAAMAAAAAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAABAAAAADoAQAAQAAABAAAAAAAAAAQVNDSUkAAABQaWNzdW0gSUQ6IDM1AP/bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/CABEIABAAEAMBIgACEQEDEQH/xAAVAAEBAAAAAAAAAAAAAAAAAAAEBf/EABQBAQAAAAAAAAAAAAAAAAAAAAT/2gAMAwEAAhADEAAAAXqhhMj/xAAZEAADAAMAAAAAAAAAAAAAAAABAgMFERP/2gAIAQEAAQUClkNit1EkQol3PT//xAAYEQACAwAAAAAAAAAAAAAAAAABAwACIf/aAAgBAwEBPwFa6jTP/8QAGBEAAgMAAAAAAAAAAAAAAAAAAAIBAyH/2gAIAQIBAT8Be12yD//EABsQAAIDAAMAAAAAAAAAAAAAAAABAhExIzKR/9oACAEBAAY/AuSHhJraOo1Wo//EABkQAAMBAQEAAAAAAAAAAAAAAAABESFBUf/aAAgBAQABPyHK09GZVWkzrMZfGL//2gAMAwEAAgADAAAAEG//xAAVEQEBAAAAAAAAAAAAAAAAAAAAAf/aAAgBAwEBPxCsP//EABURAQEAAAAAAAAAAAAAAAAAAABR/9oACAECAQE/EJG//8QAGxABAAMAAwEAAAAAAAAAAAAAAQARITFBcZH/2gAIAQEAAT8QLFXq/H7McnD32o87LUuUMEBVpM48n//Z', + }); + + await new Promise((resolve) => { + scatterplot.subscribe('backgroundImageReady', resolve, 1); + }); + + expect(scatterplot.get('backgroundImage').width).toBe(16); + + scatterplot.destroy(); +}); + +test('set({ cameraTarget, cameraDistance, cameraRotation, cameraView })', (t) => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const settings = { + cameraTarget: [1.337, 1.337], + cameraDistance: 1.337, + cameraRotation: Math.PI / 1.337, + }; + + const comparators = { + cameraTarget: (a, b) => flatArrayEqual(a, b, floatEqual), + cameraDistance: (a, b) => floatEqual(a, b), + cameraRotation: (a, b) => floatEqual(a, b), + }; + + Object.entries(settings).forEach(([setting, value]) => { + scatterplot.set({ [setting]: value }); + + expect( + comparators[setting](value, scatterplot.get(setting)) + ).toBe(true); + + scatterplot.set({ [setting]: null }); + + expect( + comparators[setting](value, scatterplot.get(setting)) + ).toBe(true); + }); + + scatterplot.destroy(); +}); + +test('set({ mouseMode })', () => { + const canvas = createCanvas(); + + let scatterplot = createScatterplot({ canvas }); + expect(scatterplot.get('mouseMode')).toBe('panZoom'); + scatterplot.destroy(); + + scatterplot = createScatterplot({ canvas, mouseMode: 'panZoom' }); + expect(scatterplot.get('mouseMode')).toBe('panZoom'); + scatterplot.destroy(); + + scatterplot = createScatterplot({ canvas, mouseMode: 'lasso' }); + expect(scatterplot.get('mouseMode')).toBe('lasso'); + scatterplot.destroy(); + + scatterplot = createScatterplot({ canvas, mouseMode: 'rotate' }); + expect(scatterplot.get('mouseMode')).toBe('rotate'); + scatterplot.destroy(); + + scatterplot = createScatterplot({ canvas, mouseMode: 'invalid' }); + expect(scatterplot.get('mouseMode')).toBe('panZoom'); + + scatterplot.set({ mouseMode: 'lasso' }); + expect(scatterplot.get('mouseMode')).toBe('lasso'); + + scatterplot.set({ mouseMode: 'panZoom' }); + expect(scatterplot.get('mouseMode')).toBe('panZoom'); + + scatterplot.set({ mouseMode: 'rotate' }); + expect(scatterplot.get('mouseMode')).toBe('rotate'); + + scatterplot.set({ mouseMode: 'invalid' }); + expect(scatterplot.get('mouseMode')).toBe('panZoom'); + + scatterplot.destroy(); +}); + +test( + 'set({ colorBy, opacityBy, sizeBy, pointConnectionColorBy, pointConnectionOpacityBy, pointConnectionSizeBy })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + [ + 'colorBy', + 'opacityBy', + 'sizeBy', + 'pointConnectionColorBy', + 'pointConnectionOpacityBy', + 'pointConnectionSizeBy', + ].forEach((property) => { + Object.entries(valueVariants).forEach(([value, variants]) => { + variants.forEach((variant) => { + scatterplot.set({ [property]: variant }); + + expect(scatterplot.get(property)).toBe(value); + }); + }); + + scatterplot.set({ [property]: null }); + + expect(scatterplot.get(property)).toBe(null); + }); + + scatterplot.destroy(); + } +); + +test( + 'set({ pointColor, pointColorActive, pointColorHover, pointConnectionColor, pointConnectionColorActive, pointConnectionColorHover }) single color', + () => { + const canvas = createCanvas(); + const scatterplot = createScatterplot({ canvas }); + + const rgbaPointColor = [ + 0.22745098039215686, 0.47058823529411764, 0.6666666666666666, 1, + ]; + const rgbaPointColorActive = [0, 0.5529411764705883, 1, 1]; + const rgbaPointColorHover = [0, 0.5529411764705883, 1, 1]; + + // Set a single color + scatterplot.set({ + pointColor: '#3a78aa', + pointColorActive: '#008dff', + pointColorHover: '#008dff', + pointConnectionColor: 'inherit', + pointConnectionColorActive: 'inherit', + pointConnectionColorHover: 'inherit', + }); + + expect(scatterplot.get('pointColor')).toEqual(rgbaPointColor); + expect(scatterplot.get('pointColorActive')).toEqual(rgbaPointColorActive); + expect(scatterplot.get('pointColorHover')).toEqual(rgbaPointColorHover); + expect(scatterplot.get('pointConnectionColor')).toEqual(rgbaPointColor); + expect(scatterplot.get('pointConnectionColorActive')).toEqual(rgbaPointColorActive); + expect(scatterplot.get('pointConnectionColorHover')).toEqual(rgbaPointColorHover); + + // Set custom point connection color + scatterplot.set({ + pointConnectionColor: '#ff0000', + pointConnectionColorActive: '#00ff00', + pointConnectionColorHover: '#0000ff', + }); + + expect(scatterplot.get('pointConnectionColor')).toEqual([1, 0, 0, 1]); + expect(scatterplot.get('pointConnectionColorActive')).toEqual([0, 1, 0, 1]); + expect(scatterplot.get('pointConnectionColorHover')).toEqual([0, 0, 1, 1]); + + // Set an invalid color, which should default to white + scatterplot.set({ + pointColor: 'shouldnotwork', + }); + + expect( + scatterplot.get('pointColor').every((component) => component === 1) + ).toBe(true); + + scatterplot.destroy(); + } +); + +test( + 'set({ pointColor, pointColorActive, pointColorHover }) multiple colors', + () => { + const canvas = createCanvas(); + const scatterplot = createScatterplot({ canvas }); + + const pointColor = [ + [0, 0.5, 1, 0.5], + [1, 0.5, 0, 0.5], + ]; + const pointColorActive = [ + [0.5, 0, 1, 0.5], + [1, 0, 0.5, 0.5], + ]; + const pointColorHover = [ + [1, 0.5, 0, 0.5], + [0, 0.5, 1, 0.5], + ]; + + // Set a single color + scatterplot.set({ + pointColor, + pointColorActive, + pointColorHover, + pointConnectionColor: 'inherit', + pointConnectionColorActive: 'inherit', + pointConnectionColorHover: 'inherit', + }); + + expect( + scatterplot + .get('pointColor') + .every((color, i) => color.every((c, j) => c === pointColor[i][j])) + ).toBe(true); + + expect( + scatterplot + .get('pointColorActive') + .every((color, i) => + color.every((c, j) => c === pointColorActive[i][j]) + ) + ).toBe(true); + + expect( + scatterplot + .get('pointColorHover') + .every((color, i) => + color.every((c, j) => c === pointColorHover[i][j]) + ) + ).toBe(true); + + expect( + scatterplot + .get('pointConnectionColor') + .every((color, i) => color.every((c, j) => c === pointColor[i][j])) + ).toBe(true); + + expect( + scatterplot + .get('pointConnectionColorActive') + .every((color, i) => + color.every((c, j) => c === pointColorActive[i][j]) + ) + ).toBe(true); + + expect( + scatterplot + .get('pointConnectionColorHover') + .every((color, i) => + color.every((c, j) => c === pointColorHover[i][j]) + ) + ).toBe(true); + + scatterplot.set({ + pointConnectionColor: ['#ff0000', '#ff00ff'], + pointConnectionColorActive: ['#ffff00', '#0000ff'], + pointConnectionColorHover: ['#000000', '#ffffff'], + }); + + expect(scatterplot.get('pointConnectionColor')).toEqual([ + [1, 0, 0, 1], + [1, 0, 1, 1], + ], + ); + + expect(scatterplot.get('pointConnectionColorActive')).toEqual([ + [1, 1, 0, 1], + [0, 0, 1, 1], + ]); + + expect(scatterplot.get('pointConnectionColorHover')).toEqual([ + [0, 0, 0, 1], + [1, 1, 1, 1], + ]); + + scatterplot.destroy(); + } +); + +test( + 'set({ opacity, pointConnectionOpacity, pointConnectionOpacityActive })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + let opacity = 0.5; + + scatterplot.set({ opacity }); + + expect(scatterplot.get('opacity')).toBe(opacity); + + expect(scatterplot.get('pointConnectionOpacity')).toBe( + DEFAULT_POINT_CONNECTION_OPACITY + ); + + expect(scatterplot.get('pointConnectionOpacityActive')).toBe( + scatterplot.get('pointConnectionOpacityActive'), + ); + + scatterplot.set({ + opacity: 0, + pointConnectionOpacity: opacity, + pointConnectionOpacityActive: opacity, + }); + + expect(scatterplot.get('opacity')).toBe(opacity); + expect(scatterplot.get('pointConnectionOpacity')).toBe(opacity); + expect(scatterplot.get('pointConnectionOpacityActive')).toBe(opacity); + + opacity = [0.5, 0.75, 1]; + + scatterplot.set({ + opacity, + pointConnectionOpacity: opacity, + pointConnectionOpacityActive: opacity, + }); + + expect(scatterplot.get('opacity')).toEqual(opacity); + expect(scatterplot.get('pointConnectionOpacity')).toEqual(opacity); + expect(scatterplot.get('pointConnectionOpacityActive')).toEqual(0.5); + + scatterplot.destroy(); + } +); + +test( + 'set({ lassoColor, lassoMinDist, lassoMinDelay, lassoClearEvent })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + // Check default lasso color, min distance, and min delay + expect(scatterplot.get('lassoColor')).toEqual(DEFAULT_LASSO_COLOR); + expect(scatterplot.get('lassoMinDist')).toEqual(DEFAULT_LASSO_MIN_DIST); + expect(scatterplot.get('lassoMinDelay')).toEqual(DEFAULT_LASSO_MIN_DELAY); + expect(scatterplot.get('lassoClearEvent')).toEqual(DEFAULT_LASSO_CLEAR_EVENT); + + const lassoColor = [1, 0, 0, 1]; + const lassoMinDist = 10; + const lassoMinDelay = 150; + const lassoClearEvent = 'deselect'; + + scatterplot.set({ + lassoColor, + lassoMinDist, + lassoMinDelay, + lassoClearEvent, + }); + + expect(scatterplot.get('lassoColor')).toEqual(lassoColor); + expect(scatterplot.get('lassoMinDist')).toEqual(lassoMinDist); + expect(scatterplot.get('lassoMinDelay')).toEqual(lassoMinDelay); + expect(scatterplot.get('lassoClearEvent')).toEqual(lassoClearEvent); + + scatterplot.set({ + lassoColor: null, + lassoMinDist: null, + lassoMinDelay: null, + lassoClearEvent: null, + }); + + expect(scatterplot.get('lassoColor')).toEqual(lassoColor); + expect(scatterplot.get('lassoMinDist')).toEqual(lassoMinDist); + expect(scatterplot.get('lassoMinDelay')).toEqual(lassoMinDelay); + expect(scatterplot.get('lassoClearEvent')).toEqual(lassoClearEvent); + + scatterplot.destroy(); + } +); + +test( + 'set({ pointOutlineWidth })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const pointOutlineWidth = 42; + + scatterplot.set({ pointOutlineWidth }); + + expect(scatterplot.get('pointOutlineWidth')).toEqual(pointOutlineWidth); + + scatterplot.set({ pointOutlineWidth: 0 }); + + expect(scatterplot.get('pointOutlineWidth')).toEqual(pointOutlineWidth); + + scatterplot.destroy(); + } +); + +test( + 'set({ pointSize, pointConnectionSize, pointConnectionSizeActive })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const pointSize = 42; + + scatterplot.set({ pointSize }); + + expect(scatterplot.get('pointSize')).toEqual(pointSize); + expect(scatterplot.get('pointConnectionSize')).toEqual( + DEFAULT_POINT_CONNECTION_SIZE + ); + expect(scatterplot.get('pointConnectionSizeActive')).toEqual( + DEFAULT_POINT_CONNECTION_SIZE_ACTIVE + ); + + scatterplot.set({ + pointSize: 0, + pointConnectionSize: pointSize, + pointConnectionSizeActive: pointSize, + }); + + expect(scatterplot.get('pointSize')).toEqual(pointSize); + expect(scatterplot.get('pointConnectionSize')).toEqual(pointSize); + expect(scatterplot.get('pointConnectionSizeActive')).toEqual(pointSize); + + scatterplot.set({ + pointSize: [2, 4, 6], + pointConnectionSize: [2, 4, 6], + pointConnectionSizeActive: [2, 4, 6], + }); + + expect(scatterplot.get('pointSize')).toEqual([2, 4, 6]); + expect(scatterplot.get('pointConnectionSize')).toEqual([2, 4, 6]); + expect(scatterplot.get('pointConnectionSizeActive')).toBe(pointSize); + + scatterplot.destroy(); + } +); + +test( + 'set({ pointSizeSelected })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const pointSizeSelected = 42; + + scatterplot.set({ pointSizeSelected }); + + expect(scatterplot.get('pointSizeSelected')).toEqual(pointSizeSelected); + + scatterplot.set({ pointSizeSelected: 0 }); + + expect(scatterplot.get('pointSizeSelected')).toEqual(pointSizeSelected); + + scatterplot.destroy(); + } +); + +test( + 'set({ showReticle, reticleColor })', + () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + expect(scatterplot.get('showReticle')).toEqual(DEFAULT_SHOW_RETICLE); + expect(scatterplot.get('reticleColor')).toEqual(DEFAULT_RETICLE_COLOR); + + const showReticle = !DEFAULT_SHOW_RETICLE; + const reticleColor = [1, 0, 0, 0.5]; + + scatterplot.set({ showReticle, reticleColor }); + + expect(scatterplot.get('showReticle')).toEqual(showReticle); + expect(scatterplot.get('reticleColor')).toEqual(reticleColor); + + scatterplot.set({ showReticle: null }); + + expect(scatterplot.get('showReticle')).toEqual(showReticle); + + scatterplot.set({ reticleColor: null }); + + expect(scatterplot.get('reticleColor')).toEqual(reticleColor); + + scatterplot.destroy(); + } +); diff --git a/tests/index.js b/tests/index.js deleted file mode 100644 index a3ba726..0000000 --- a/tests/index.js +++ /dev/null @@ -1,3597 +0,0 @@ -/* eslint no-console: 1, no-undef: 1, no-unused-vars: 1 */ - -import '@babel/polyfill'; -import { test } from 'zora'; -import { scaleLinear } from 'd3-scale'; -import { mat4 } from 'gl-matrix'; -import { isFunction, nextAnimationFrame } from '@flekschas/utils'; - -import { version } from '../package.json'; - -import createScatterplot, { - createRegl, - createRenderer, - createSpatialIndex, - createTextureFromUrl, - checkSupport, -} from '../src'; - -import { - DEFAULT_COLOR_NORMAL, - DEFAULT_COLOR_ACTIVE, - DEFAULT_COLOR_HOVER, - DEFAULT_COLOR_BG, - DEFAULT_HEIGHT, - DEFAULT_LASSO_COLOR, - DEFAULT_SHOW_RETICLE, - DEFAULT_RETICLE_COLOR, - DEFAULT_POINT_OUTLINE_WIDTH, - DEFAULT_POINT_SIZE, - DEFAULT_POINT_SIZE_SELECTED, - DEFAULT_OPACITY_INACTIVE_MAX, - DEFAULT_OPACITY_INACTIVE_SCALE, - DEFAULT_WIDTH, - DEFAULT_LASSO_MIN_DELAY, - DEFAULT_LASSO_MIN_DIST, - DEFAULT_LASSO_CLEAR_EVENT, - DEFAULT_POINT_CONNECTION_OPACITY, - DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, - DEFAULT_POINT_CONNECTION_SIZE, - DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, - DEFAULT_POINT_SCALE_MODE, - DEFAULT_GAMMA, - KEY_ACTION_LASSO, - KEY_ACTION_ROTATE, - SINGLE_CLICK_DELAY, - DEFAULT_OPACITY, - DEFAULT_IMAGE_LOAD_TIMEOUT, - IMAGE_LOAD_ERROR, - ERROR_POINTS_NOT_DRAWN, - CONTINUOUS, - CATEGORICAL, -} from '../src/constants'; - -import { toRgba, isNormFloatArray, isValidBBox } from '../src/utils'; - -import { - asyncForEach, - createCanvas, - createMouseEvent, - createKeyboardEvent, - flatArrayEqual, - wait, - capitalize, - catchError, - isSameElements, - getPixelSum, -} from './utils'; - -const EPS = 1e-7; - -const floatEqual = (a, b) => Math.abs(a - b) <= EPS; - -const valueVariants = { - valueZ: ['category', 'value1', 'valueA', 'valueZ', 'z'], - valueW: ['value', 'value2', 'valueB', 'valueW', 'w'], -}; - -test('regl-scatterplot', async (t2) => { - /* ----------------------------- constructors ----------------------------- */ - - await t2.test( - 'createRegl()', - catchError((t) => { - const dim = 200; - const canvas = createCanvas(dim, dim); - const gl = canvas.getContext('webgl'); - - t.equal(gl.drawingBufferWidth, dim, `width should be ${dim}px`); - t.equal(gl.drawingBufferHeight, dim, `height should be ${dim}px`); - - const regl = createRegl(canvas); - - t.ok(!!regl, 'regl should be instanciated'); - - regl.destroy(); - }) - ); - - await t2.test( - 'createScatterplot()', - catchError((t) => { - const canvas = createCanvas(null, null); - const scatterplot = createScatterplot({ canvas }); - - t.equal(scatterplot.get('canvas'), canvas, 'canvas object should equal'); - t.equal( - scatterplot.get('backgroundColor'), - DEFAULT_COLOR_BG, - 'scatterplot should have a default backgroundColor' - ); - t.equal( - scatterplot.get('pointColor'), - DEFAULT_COLOR_NORMAL, - 'scatterplot should have a default pointColor' - ); - t.equal( - scatterplot.get('pointColorActive'), - DEFAULT_COLOR_ACTIVE, - 'scatterplot should have a default pointColorActive' - ); - t.equal( - scatterplot.get('pointColorHover'), - DEFAULT_COLOR_HOVER, - 'scatterplot should have a default pointColorHover' - ); - t.equal( - scatterplot.get('pointSize'), - DEFAULT_POINT_SIZE, - 'scatterplot should have default point size' - ); - t.equal( - scatterplot.get('pointSizeSelected'), - DEFAULT_POINT_SIZE_SELECTED, - 'scatterplot should have default selected point size' - ); - t.equal( - scatterplot.get('pointOutlineWidth'), - DEFAULT_POINT_OUTLINE_WIDTH, - 'scatterplot should have default point outline width' - ); - t.equal( - scatterplot.get('opacity'), - DEFAULT_OPACITY, - 'scatterplot should have default point opacity' - ); - t.equal( - scatterplot.get('opacityInactiveMax'), - DEFAULT_OPACITY_INACTIVE_MAX, - 'scatterplot should have default inactive point max opacity' - ); - t.equal( - scatterplot.get('opacityInactiveScale'), - DEFAULT_OPACITY_INACTIVE_SCALE, - 'scatterplot should have default inactive point opacity scaling' - ); - t.equal( - scatterplot.get('width'), - DEFAULT_WIDTH, - 'scatterplot should have default width' - ); - t.equal( - scatterplot.get('height'), - DEFAULT_HEIGHT, - 'scatterplot should have default height' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'createScatterplot({ cameraTarget, cameraDistance, cameraRotation, cameraView })', - catchError((t) => { - const cameraTarget = [1, 1]; - const cameraDistance = 2; - const cameraRotation = Math.PI / 4; - const scatterplot = createScatterplot({ - cameraTarget, - cameraDistance, - cameraRotation, - }); - - t.ok( - flatArrayEqual([1, 1], scatterplot.get('cameraTarget'), floatEqual), - `The camera target should be ${cameraTarget}` - ); - - t.ok( - floatEqual(cameraDistance, scatterplot.get('cameraDistance')), - `The camera distance should be ${cameraDistance}` - ); - - t.ok( - floatEqual(cameraRotation, scatterplot.get('cameraRotation')), - `The camera rotation should be ${cameraRotation}` - ); - - // prettier-ignore - const cameraView = new Float32Array([ - 0.5, 0, 0, 0.5, - 0, 0.5, 0, 0.5, - 0, 0, 0.5, 0, - 0, 0, 0, 1, - ]); - const scatterplot2 = createScatterplot({ cameraView }); - - t.equal( - cameraView, - scatterplot2.get('cameraView'), - `The camera view should be ${cameraView}` - ); - - scatterplot.destroy(); - scatterplot2.destroy(); - }) - ); - - await t2.test( - 'createTextureFromUrl()', - catchError(async (t) => { - const regl = createRegl(createCanvas()); - - try { - const texture = await createTextureFromUrl( - regl, - 'https://picsum.photos/300/200/' - ); - - t.equal( - texture._reglType, // eslint-disable-line no-underscore-dangle - 'texture2d', - 'texture should be a Regl texture object' - ); - } catch (e) { - if (e.message === IMAGE_LOAD_ERROR) { - t.skip('Skipping because image loading timed out'); - } else { - t.fail('Failed to load image from URL'); - } - } - - regl.destroy(); - }) - ); - - await t2.test( - 'createRenderer()', - catchError((t) => { - const canvas = createCanvas(); - const regl = createRegl(canvas); - const renderer = createRenderer({ canvas, regl }); - - t.ok(!!renderer, 'renderer should be instanciated'); - t.equal(renderer.canvas, canvas, 'canvas should be a canvas element'); - t.equal(renderer.regl, regl, 'regl should be a regl instance'); - t.equal( - renderer.gamma, - DEFAULT_GAMMA, - `renderer should have gamma set to ${DEFAULT_GAMMA}` - ); - t.ok(isFunction(renderer.render), 'renderer should have render function'); - t.ok( - isFunction(renderer.onFrame), - 'renderer should have onFrame function' - ); - t.ok( - isFunction(renderer.refresh), - 'renderer should have refresh function' - ); - t.ok( - isFunction(renderer.destroy), - 'renderer should have destroy function' - ); - - const sp1 = createScatterplot({ renderer }); - const sp2 = createScatterplot({ renderer }); - - t.equal(sp1.get('renderer'), renderer, 'sp1.renderer should be renderer'); - t.equal( - sp2.get('renderer'), - sp1.get('renderer'), - 'sp1.renderer should be the same as sp1.renderer' - ); - - sp1.destroy(); - sp2.destroy(); - - // Renderer should have not been destroyed - t.equal( - renderer.canvas, - canvas, - 'canvas should still be a canvas element' - ); - t.equal(renderer.regl, regl, 'regl should still be a regl instance'); - - const sp3 = createScatterplot({ renderer }); - t.equal(sp3.get('renderer'), renderer, 'sp3.renderer should be renderer'); - - renderer.gamma = 10; - t.equal(renderer.gamma, 10, 'gamma should be 10'); - - sp3.destroy(); - renderer.destroy(); - - // Now the renderer should have been destroyed - t.equal(renderer.canvas, undefined, 'canvas should be undefined'); - t.equal(renderer.regl, undefined, 'regl should be undefined'); - }) - ); - - await t2.test( - 'createSpatialIndex', - catchError(async (t) => { - const points = { - x: [-1, 1, 0, -1, 1], - y: [1, 1, 0, -1, -1], - z: [0.2, 0.4, 0.6, 0.8, 1], - }; - - const spatialIndex1 = await createSpatialIndex(points); - const spatialIndex2 = await createSpatialIndex(points, { - useWorker: true, - }); - - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - await scatterplot.draw(points, { spatialIndex: spatialIndex1 }); - - await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); - - t.equal( - scatterplot.get('pointsInView'), - [1, 2], - 'should have points #1 and #2 in the view' - ); - - await scatterplot.draw(points, { spatialIndex: spatialIndex2 }); - - await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); - - t.equal( - scatterplot.get('pointsInView'), - [2, 3], - 'should have points #2 and #3 in the view' - ); - - scatterplot.destroy(); - }) - ); - - /* --------------------------- get() and set() ---------------------------- */ - - await t2.test( - 'get("canvas"), get("regl"), and get("version")', - catchError(async (t) => { - const canvas = createCanvas(); - const regl = createRegl(canvas); - const renderer = createRenderer({ regl }); - const scatterplot = createScatterplot({ canvas, renderer }); - - t.equal( - scatterplot.get('canvas'), - canvas, - 'canvas should be a canvas element' - ); - - t.equal(scatterplot.get('regl'), regl, 'regl should be a regl instance'); - - t.equal( - scatterplot.get('version'), - version, - `version should be set to ${version}` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ width, height })', - catchError((t) => { - const w1 = 200; - const h1 = 200; - - const canvas = createCanvas(w1, h1); - const gl = canvas.getContext('webgl'); - const scatterplot = createScatterplot({ canvas, width: w1, height: h1 }); - - t.equal( - gl.drawingBufferWidth, - w1 * window.devicePixelRatio, - `width should be ${w1 * window.devicePixelRatio}px` - ); - t.equal( - gl.drawingBufferHeight, - h1 * window.devicePixelRatio, - `height should be ${h1 * window.devicePixelRatio}px` - ); - - const w2 = 400; - const h2 = 300; - - scatterplot.set({ width: w2, height: h2 }); - - t.equal(scatterplot.get('width'), w2, `width should be set to ${w2}px`); - t.equal(scatterplot.get('height'), h2, `height should be set to ${h2}px`); - - t.equal( - gl.drawingBufferWidth, - w2 * window.devicePixelRatio, - `width should be set to ${w2 * window.devicePixelRatio}px` - ); - t.equal( - gl.drawingBufferHeight, - h2 * window.devicePixelRatio, - `height should be set to ${h2 * window.devicePixelRatio}px` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ aspectRatio })', - catchError((t) => { - const canvas = createCanvas(400, 200); - const scatterplot = createScatterplot({ - canvas, - width: 400, - height: 200, - }); - - const aspectRatio = 2; - scatterplot.set({ aspectRatio }); - - t.equal( - scatterplot.get('aspectRatio'), - aspectRatio, - `aspectRatio should be set to ${aspectRatio}` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ backgroundColor })', - catchError((t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const backgroundHex = '#ff0000'; - const backgroundNrgba = [1, 0, 0, 1]; - scatterplot.set({ backgroundColor: backgroundHex }); - - t.ok( - scatterplot - .get('backgroundColor') - .every((v, i) => v === backgroundNrgba[i]), - 'background color should be red and and converted to normalized RGBA' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ backgroundImage })', - catchError(async (t) => { - const canvas = createCanvas(); - const regl = createRegl(canvas); - const scatterplot = createScatterplot({ canvas, regl }); - - try { - const backgroundImage = await createTextureFromUrl( - regl, - 'https://picsum.photos/300/200/' - ); - - scatterplot.set({ backgroundImage }); - - t.equal( - scatterplot.get('backgroundImage'), - backgroundImage, - 'background image should be a Regl texture' - ); - } catch (e) { - if (e.message === IMAGE_LOAD_ERROR) { - t.skip(`Failed to load image from URL: ${e.message}`); - } else { - t.fail('Could not create background image from URL'); - } - } - - try { - const backgroundImage = await scatterplot.createTextureFromUrl( - 'https://picsum.photos/300/200/' - ); - - scatterplot.set({ backgroundImage }); - - t.equal( - scatterplot.get('backgroundImage'), - backgroundImage, - 'background image should be a Regl texture' - ); - - scatterplot.set({ backgroundImage: null }); - - t.equal( - scatterplot.get('backgroundImage'), - null, - 'background image should be nullifyable' - ); - } catch (e) { - if (e.message === IMAGE_LOAD_ERROR) { - t.skip(`Failed to load image from URL: ${e.message}`); - } else { - t.fail('Could not create background image from URL'); - } - } - - try { - await new Promise((resolve, reject) => { - scatterplot.subscribe('backgroundImageReady', resolve, 1); - scatterplot.set({ - backgroundImage: 'https://picsum.photos/300/200/', - }); - setTimeout(() => { - reject(new Error(IMAGE_LOAD_ERROR)); - }, DEFAULT_IMAGE_LOAD_TIMEOUT); - }); - - t.equal( - scatterplot.get('backgroundImage').width, - 300, - 'background image should be loaded by scatterplot' - ); - } catch (e) { - if (e.message === IMAGE_LOAD_ERROR) { - t.skip(`Failed to load image from URL: ${e.message}`); - } else { - t.fail('Could not create background image from URL'); - } - } - - // Base64 image - scatterplot.set({ - backgroundImage: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QDeRXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAAA4YwAA6AMAADhjAADoAwAABwAAkAcABAAAADAyMTABkQcABAAAAAECAwCGkgcAFQAAAMAAAAAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAABAAAAADoAQAAQAAABAAAAAAAAAAQVNDSUkAAABQaWNzdW0gSUQ6IDM1AP/bAEMACAYGBwYFCAcHBwkJCAoMFA0MCwsMGRITDxQdGh8eHRocHCAkLicgIiwjHBwoNyksMDE0NDQfJzk9ODI8LjM0Mv/bAEMBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/CABEIABAAEAMBIgACEQEDEQH/xAAVAAEBAAAAAAAAAAAAAAAAAAAEBf/EABQBAQAAAAAAAAAAAAAAAAAAAAT/2gAMAwEAAhADEAAAAXqhhMj/xAAZEAADAAMAAAAAAAAAAAAAAAABAgMFERP/2gAIAQEAAQUClkNit1EkQol3PT//xAAYEQACAwAAAAAAAAAAAAAAAAABAwACIf/aAAgBAwEBPwFa6jTP/8QAGBEAAgMAAAAAAAAAAAAAAAAAAAIBAyH/2gAIAQIBAT8Be12yD//EABsQAAIDAAMAAAAAAAAAAAAAAAABAhExIzKR/9oACAEBAAY/AuSHhJraOo1Wo//EABkQAAMBAQEAAAAAAAAAAAAAAAABESFBUf/aAAgBAQABPyHK09GZVWkzrMZfGL//2gAMAwEAAgADAAAAEG//xAAVEQEBAAAAAAAAAAAAAAAAAAAAAf/aAAgBAwEBPxCsP//EABURAQEAAAAAAAAAAAAAAAAAAABR/9oACAECAQE/EJG//8QAGxABAAMAAwEAAAAAAAAAAAAAAQARITFBcZH/2gAIAQEAAT8QLFXq/H7McnD32o87LUuUMEBVpM48n//Z', - }); - - await new Promise((resolve) => { - scatterplot.subscribe('backgroundImageReady', resolve, 1); - }); - - t.equal( - scatterplot.get('backgroundImage').width, - 16, - 'base64 background image should be supported' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ cameraTarget, cameraDistance, cameraRotation, cameraView })', - catchError((t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const settings = { - cameraTarget: [1.337, 1.337], - cameraDistance: 1.337, - cameraRotation: Math.PI / 1.337, - }; - - const comparators = { - cameraTarget: (a, b) => flatArrayEqual(a, b, floatEqual), - cameraDistance: (a, b) => floatEqual(a, b), - cameraRotation: (a, b) => floatEqual(a, b), - }; - - Object.entries(settings).forEach(([setting, value]) => { - scatterplot.set({ [setting]: value }); - - t.ok( - comparators[setting](value, scatterplot.get(setting)), - `${setting} should be set to ${value}` - ); - - scatterplot.set({ [setting]: null }); - - t.ok( - comparators[setting](value, scatterplot.get(setting)), - `${setting} should not be nullifyable` - ); - }); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ mouseMode })', - catchError((t) => { - const canvas = createCanvas(); - - let scatterplot = createScatterplot({ canvas }); - t.equal( - scatterplot.get('mouseMode'), - 'panZoom', - 'default mouseMode should be panZoom' - ); - scatterplot.destroy(); - - scatterplot = createScatterplot({ canvas, mouseMode: 'panZoom' }); - t.equal( - scatterplot.get('mouseMode'), - 'panZoom', - 'initial mouseMode should be panZoom' - ); - scatterplot.destroy(); - - scatterplot = createScatterplot({ canvas, mouseMode: 'lasso' }); - t.equal( - scatterplot.get('mouseMode'), - 'lasso', - 'initial mouseMode should be lasso' - ); - scatterplot.destroy(); - - scatterplot = createScatterplot({ canvas, mouseMode: 'rotate' }); - t.equal( - scatterplot.get('mouseMode'), - 'rotate', - 'initial mouseMode should be rotate' - ); - scatterplot.destroy(); - - scatterplot = createScatterplot({ canvas, mouseMode: 'invalid' }); - t.equal( - scatterplot.get('mouseMode'), - 'panZoom', - 'initial mouseMode should be panZoom' - ); - - scatterplot.set({ mouseMode: 'lasso' }); - t.equal( - scatterplot.get('mouseMode'), - 'lasso', - 'mouseMode should be set to lasso' - ); - - scatterplot.set({ mouseMode: 'panZoom' }); - t.equal( - scatterplot.get('mouseMode'), - 'panZoom', - 'mouseMode should be set to panZoom' - ); - - scatterplot.set({ mouseMode: 'rotate' }); - t.equal( - scatterplot.get('mouseMode'), - 'rotate', - 'mouseMode should be set to rotate' - ); - - scatterplot.set({ mouseMode: 'invalid' }); - t.equal( - scatterplot.get('mouseMode'), - 'panZoom', - 'mouseMode should fall back to panZoom' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ colorBy, opacityBy, sizeBy, pointConnectionColorBy, pointConnectionOpacityBy, pointConnectionSizeBy })', - catchError((t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - [ - 'colorBy', - 'opacityBy', - 'sizeBy', - 'pointConnectionColorBy', - 'pointConnectionOpacityBy', - 'pointConnectionSizeBy', - ].forEach((property) => { - Object.entries(valueVariants).forEach(([value, variants]) => { - variants.forEach((variant) => { - scatterplot.set({ [property]: variant }); - - t.equal( - scatterplot.get(property), - value, - `${property}: ${variant} should be set to ${value}` - ); - }); - }); - - scatterplot.set({ [property]: null }); - - t.equal( - scatterplot.get(property), - null, - 'colorBy should be nullifyable' - ); - }); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ pointColor, pointColorActive, pointColorHover, pointConnectionColor, pointConnectionColorActive, pointConnectionColorHover }) single color', - catchError((t) => { - const canvas = createCanvas(); - const scatterplot = createScatterplot({ canvas }); - - const rgbaPointColor = [ - 0.22745098039215686, 0.47058823529411764, 0.6666666666666666, 1, - ]; - const rgbaPointColorActive = [0, 0.5529411764705883, 1, 1]; - const rgbaPointColorHover = [0, 0.5529411764705883, 1, 1]; - - // Set a single color - scatterplot.set({ - pointColor: '#3a78aa', - pointColorActive: '#008dff', - pointColorHover: '#008dff', - pointConnectionColor: 'inherit', - pointConnectionColorActive: 'inherit', - pointConnectionColorHover: 'inherit', - }); - - t.equal( - scatterplot.get('pointColor'), - rgbaPointColor, - 'should create normalized RGBA for point color' - ); - - t.equal( - scatterplot.get('pointColorActive'), - rgbaPointColorActive, - 'should create normalized RGBA for point color active' - ); - - t.equal( - scatterplot.get('pointColorHover'), - rgbaPointColorHover, - 'should create normalized RGBA for point color hover' - ); - - t.equal( - scatterplot.get('pointConnectionColor'), - rgbaPointColor, - 'pointConnectionColor should inherit from pointColor' - ); - - t.equal( - scatterplot.get('pointConnectionColorActive'), - rgbaPointColorActive, - 'pointConnectionColorActive should inherit from pointColorActive' - ); - - t.equal( - scatterplot.get('pointConnectionColorHover'), - rgbaPointColorHover, - 'pointConnectionColorHover should inherit from pointColorHover' - ); - - // Set custom point connection color - scatterplot.set({ - pointConnectionColor: '#ff0000', - pointConnectionColorActive: '#00ff00', - pointConnectionColorHover: '#0000ff', - }); - - t.equal( - scatterplot.get('pointConnectionColor'), - [1, 0, 0, 1], - 'should create an RGBA value for pointConnectionColor' - ); - - t.equal( - scatterplot.get('pointConnectionColorActive'), - [0, 1, 0, 1], - 'should create an RGBA value for pointConnectionColorActive' - ); - - t.equal( - scatterplot.get('pointConnectionColorHover'), - [0, 0, 1, 1], - 'should create an RGBA value for pointConnectionColorHover' - ); - - // Set an invalid color, which should default to white - scatterplot.set({ - pointColor: 'shouldnotwork', - }); - - t.ok( - scatterplot.get('pointColor').every((component) => component === 1), - 'should default to white when setting an invalid color point color from before' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ pointColor, pointColorActive, pointColorHover }) multiple colors', - catchError((t) => { - const canvas = createCanvas(); - const scatterplot = createScatterplot({ canvas }); - - const pointColor = [ - [0, 0.5, 1, 0.5], - [1, 0.5, 0, 0.5], - ]; - const pointColorActive = [ - [0.5, 0, 1, 0.5], - [1, 0, 0.5, 0.5], - ]; - const pointColorHover = [ - [1, 0.5, 0, 0.5], - [0, 0.5, 1, 0.5], - ]; - - // Set a single color - scatterplot.set({ - pointColor, - pointColorActive, - pointColorHover, - pointConnectionColor: 'inherit', - pointConnectionColorActive: 'inherit', - pointConnectionColorHover: 'inherit', - }); - - t.ok( - scatterplot - .get('pointColor') - .every((color, i) => color.every((c, j) => c === pointColor[i][j])), - 'should accepts multiple normalized RGBA for point color' - ); - - t.ok( - scatterplot - .get('pointColorActive') - .every((color, i) => - color.every((c, j) => c === pointColorActive[i][j]) - ), - 'should accepts multiple normalized RGBA for point color active' - ); - - t.ok( - scatterplot - .get('pointColorHover') - .every((color, i) => - color.every((c, j) => c === pointColorHover[i][j]) - ), - 'should accepts multiple normalized RGBA for point color hover' - ); - - t.ok( - scatterplot - .get('pointConnectionColor') - .every((color, i) => color.every((c, j) => c === pointColor[i][j])), - 'pointConnectionColor should inherit multiple colors from pointColor' - ); - - t.ok( - scatterplot - .get('pointConnectionColorActive') - .every((color, i) => - color.every((c, j) => c === pointColorActive[i][j]) - ), - 'pointConnectionColorActive should inherit multiple colors from pointColorActive' - ); - - t.ok( - scatterplot - .get('pointConnectionColorHover') - .every((color, i) => - color.every((c, j) => c === pointColorHover[i][j]) - ), - 'pointConnectionColorHover should inherit multiple colors from pointColorHover' - ); - - scatterplot.set({ - pointConnectionColor: ['#ff0000', '#ff00ff'], - pointConnectionColorActive: ['#ffff00', '#0000ff'], - pointConnectionColorHover: ['#000000', '#ffffff'], - }); - - t.equal( - scatterplot.get('pointConnectionColor'), - [ - [1, 0, 0, 1], - [1, 0, 1, 1], - ], - 'should accepts multiple normalized RGBA for pointConnectionColor' - ); - - t.equal( - scatterplot.get('pointConnectionColorActive'), - [ - [1, 1, 0, 1], - [0, 0, 1, 1], - ], - 'should accepts multiple normalized RGBA for pointConnectionColorActive' - ); - - t.equal( - scatterplot.get('pointConnectionColorHover'), - [ - [0, 0, 0, 1], - [1, 1, 1, 1], - ], - 'should accepts multiple normalized RGBA for pointConnectionColorHover' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ opacity, pointConnectionOpacity, pointConnectionOpacityActive })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - let opacity = 0.5; - - scatterplot.set({ opacity }); - - t.equal( - scatterplot.get('opacity'), - opacity, - `opacity should be set to ${opacity}` - ); - - t.equal( - scatterplot.get('pointConnectionOpacity'), - DEFAULT_POINT_CONNECTION_OPACITY, - `pointConnectionOpacity should be set the default value (${DEFAULT_POINT_CONNECTION_OPACITY})` - ); - - t.equal( - scatterplot.get('pointConnectionOpacityActive'), - DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, - `pointConnectionOpacityActive should be set the default value (${DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE})` - ); - - scatterplot.set({ - opacity: 0, - pointConnectionOpacity: opacity, - pointConnectionOpacityActive: opacity, - }); - - t.equal( - scatterplot.get('opacity'), - opacity, - 'opacity should not be nullifyable' - ); - - t.equal( - scatterplot.get('pointConnectionOpacity'), - opacity, - `pointConnectionOpacity should be set to ${opacity}` - ); - - t.equal( - scatterplot.get('pointConnectionOpacityActive'), - opacity, - `pointConnectionOpacityActive should be set to ${opacity}` - ); - - opacity = [0.5, 0.75, 1]; - - scatterplot.set({ - opacity, - pointConnectionOpacity: opacity, - pointConnectionOpacityActive: opacity, - }); - - t.equal( - scatterplot.get('opacity'), - opacity, - 'should accept multiple opacities' - ); - - t.equal( - scatterplot.get('pointConnectionOpacity'), - opacity, - `pointConnectionOpacity should be set to ${opacity}` - ); - - t.equal( - scatterplot.get('pointConnectionOpacityActive'), - 0.5, - 'pointConnectionOpacityActive should **STILL BE** 0.5' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ lassoColor, lassoMinDist, lassoMinDelay, lassoClearEvent })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - // Check default lasso color, min distance, and min delay - t.equal( - scatterplot.get('lassoColor'), - DEFAULT_LASSO_COLOR, - `lassoColor should be set to ${DEFAULT_LASSO_COLOR}` - ); - t.equal( - scatterplot.get('lassoMinDist'), - DEFAULT_LASSO_MIN_DIST, - `lassoMinDist should be set to ${DEFAULT_LASSO_MIN_DIST}` - ); - t.equal( - scatterplot.get('lassoMinDelay'), - DEFAULT_LASSO_MIN_DELAY, - `lassoMinDelay should be set to ${DEFAULT_LASSO_MIN_DELAY}` - ); - t.equal( - scatterplot.get('lassoClearEvent'), - DEFAULT_LASSO_CLEAR_EVENT, - `lassoClearEvent should be set to ${DEFAULT_LASSO_CLEAR_EVENT}` - ); - - const lassoColor = [1, 0, 0, 1]; - const lassoMinDist = 10; - const lassoMinDelay = 150; - const lassoClearEvent = 'deselect'; - - scatterplot.set({ - lassoColor, - lassoMinDist, - lassoMinDelay, - lassoClearEvent, - }); - - t.equal( - scatterplot.get('lassoColor'), - lassoColor, - `lassoColor should be set to ${lassoColor}` - ); - t.equal( - scatterplot.get('lassoMinDist'), - lassoMinDist, - `lassoMinDist should be set to ${lassoMinDist}` - ); - t.equal( - scatterplot.get('lassoMinDelay'), - lassoMinDelay, - `lassoMinDelay should be set to ${lassoMinDelay}` - ); - t.equal( - scatterplot.get('lassoClearEvent'), - lassoClearEvent, - `lassoClearEvent should be set to ${lassoClearEvent}` - ); - - scatterplot.set({ - lassoColor: null, - lassoMinDist: null, - lassoMinDelay: null, - lassoClearEvent: null, - }); - - t.equal( - scatterplot.get('lassoColor'), - lassoColor, - 'lassoColor should not be nullifyable' - ); - t.equal( - scatterplot.get('lassoMinDist'), - lassoMinDist, - 'lassoMinDist should not be nullifyable' - ); - t.equal( - scatterplot.get('lassoMinDelay'), - lassoMinDelay, - 'lassoMinDelay should not be nullifyable' - ); - t.equal( - scatterplot.get('lassoClearEvent'), - lassoClearEvent, - 'lassoClearEvent should not be nullifyable' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ pointOutlineWidth })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const pointOutlineWidth = 42; - - scatterplot.set({ pointOutlineWidth }); - - t.equal( - scatterplot.get('pointOutlineWidth'), - pointOutlineWidth, - `pointOutlineWidth should be set to ${pointOutlineWidth}` - ); - - scatterplot.set({ pointOutlineWidth: 0 }); - - t.equal( - scatterplot.get('pointOutlineWidth'), - pointOutlineWidth, - 'pointOutlineWidth should not be nullifyable' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ pointSize, pointConnectionSize, pointConnectionSizeActive })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const pointSize = 42; - - scatterplot.set({ pointSize }); - - t.equal( - scatterplot.get('pointSize'), - pointSize, - `pointSize should be set to ${pointSize}` - ); - - t.equal( - scatterplot.get('pointConnectionSize'), - DEFAULT_POINT_CONNECTION_SIZE, - `pointConnectionSize should be set the default value (${DEFAULT_POINT_CONNECTION_SIZE})` - ); - - t.equal( - scatterplot.get('pointConnectionSizeActive'), - DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, - `pointConnectionSizeActive should be set the default value (${DEFAULT_POINT_CONNECTION_SIZE_ACTIVE})` - ); - - scatterplot.set({ - pointSize: 0, - pointConnectionSize: pointSize, - pointConnectionSizeActive: pointSize, - }); - - t.equal( - scatterplot.get('pointSize'), - pointSize, - 'pointSize should not be nullifyable' - ); - - t.equal( - scatterplot.get('pointConnectionSize'), - pointSize, - `pointConnectionSize should be set to ${pointSize}` - ); - - t.equal( - scatterplot.get('pointConnectionSizeActive'), - pointSize, - `pointConnectionSizeActive should be set to ${pointSize}` - ); - - scatterplot.set({ - pointSize: [2, 4, 6], - pointConnectionSize: [2, 4, 6], - pointConnectionSizeActive: [2, 4, 6], - }); - - t.equal( - scatterplot.get('pointSize'), - [2, 4, 6], - 'pointSize should accept multiple sizes' - ); - - t.equal( - scatterplot.get('pointConnectionSize'), - [2, 4, 6], - 'pointConnectionSize should accept multiple sizes' - ); - - t.equal( - scatterplot.get('pointConnectionSizeActive'), - pointSize, - `pointConnectionSize should **STILL BE** ${pointSize}` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ pointSizeSelected })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const pointSizeSelected = 42; - - scatterplot.set({ pointSizeSelected }); - - t.equal( - scatterplot.get('pointSizeSelected'), - pointSizeSelected, - `pointSizeSelected should be set to ${pointSizeSelected}` - ); - - scatterplot.set({ pointSizeSelected: 0 }); - - t.equal( - scatterplot.get('pointSizeSelected'), - pointSizeSelected, - 'pointSizeSelected should not be nullifyable' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'set({ showReticle, reticleColor })', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - t.equal( - scatterplot.get('showReticle'), - DEFAULT_SHOW_RETICLE, - `showReticle should be set to ${JSON.stringify( - DEFAULT_SHOW_RETICLE - )} by default` - ); - - t.equal( - scatterplot.get('reticleColor'), - DEFAULT_RETICLE_COLOR, - `reticleColor should be set to ${DEFAULT_RETICLE_COLOR} by default` - ); - - const showReticle = !DEFAULT_SHOW_RETICLE; - const reticleColor = [1, 0, 0, 0.5]; - - scatterplot.set({ showReticle, reticleColor }); - - t.equal( - scatterplot.get('showReticle'), - showReticle, - `showReticle should be set to ${showReticle}` - ); - - scatterplot.set({ showReticle: null }); - - t.equal( - scatterplot.get('showReticle'), - showReticle, - 'showReticle should not be nullifyable' - ); - - t.equal( - scatterplot.get('reticleColor'), - reticleColor, - `reticleColor should be set to ${reticleColor}` - ); - - scatterplot.set({ reticleColor: null }); - - t.equal( - scatterplot.get('reticleColor'), - reticleColor, - 'reticleColor should not be nullifyable' - ); - - scatterplot.destroy(); - }) - ); - - /* --------------------------------- events ------------------------------- */ - - await t2.test( - 'init and destroy events', - catchError(async (t) => { - const canvas = createCanvas(200, 200); - const scatterplot = createScatterplot({ - canvas, - width: 200, - height: 200, - }); - - const whenInit = new Promise((resolve) => { - scatterplot.subscribe('init', resolve, 1); - }); - const whenDestroy = new Promise((resolve) => { - scatterplot.subscribe('destroy', resolve, 1); - }); - - await whenInit; - - scatterplot.destroy(); - - await whenDestroy; - - t.equal(true, true, '"init" and "destroy" event fired'); - }) - ); - - await t2.test( - 'throw an error when calling draw() after destroy()', - catchError(async (t) => { - const canvas = createCanvas(200, 200); - const scatterplot = createScatterplot({ - canvas, - width: 200, - height: 200, - }); - - await scatterplot.draw([[0, 0]]); - - scatterplot.destroy(); - - try { - await scatterplot.draw([[0, 0]]); - t.fail( - 'should have thrown an error because the scatterplot instance is destroyed' - ); - } catch (e) { - t.eq(e.message, 'The instance was already destroyed'); - } - }) - ); - - await t2.test( - 'test that `isPointsDrawn` is set correctly', - catchError(async (t) => { - const canvas = createCanvas(200, 200); - const scatterplot = createScatterplot({ - canvas, - width: 200, - height: 200, - }); - - t.equal(scatterplot.get('isPointsDrawn'), false); - t.equal(scatterplot.get('isDestroyed'), false); - - await scatterplot.draw([[0, 0]]); - - t.equal(scatterplot.get('isPointsDrawn'), true); - - scatterplot.destroy(); - - t.equal(scatterplot.get('isDestroyed'), true); - t.equal(scatterplot.get('isPointsDrawn'), false); - }) - ); - - await t2.test( - 'do _not_ throw an error when calling draw() _before_ destroy()', - catchError(async (t) => { - const canvas = createCanvas(200, 200); - const scatterplot = createScatterplot({ - canvas, - width: 200, - height: 200, - }); - - let numDraws = 0; - scatterplot.subscribe('draw', () => ++numDraws); - - scatterplot.draw([[0, 0]]); - scatterplot.destroy(); - - t.equal( - numDraws, - 0, - 'draw call should have been canceled due to the destroy call' - ); - }) - ); - - await t2.test( - 'test that draw() recognized the correct data type of z and w', - catchError(async (t) => { - const canvas = createCanvas(200, 200); - const scatterplot = createScatterplot({ - canvas, - width: 200, - height: 200, - }); - - await scatterplot.draw([[0, 0]]); - - t.equal( - scatterplot.get('zDataType'), - CATEGORICAL, - `Default z data type should be ${CATEGORICAL}` - ); - - t.equal( - scatterplot.get('wDataType'), - CATEGORICAL, - `Default w data type should be ${CATEGORICAL}` - ); - - await scatterplot.draw([[0, 0, 1, 1]]); - - t.equal( - scatterplot.get('zDataType'), - CATEGORICAL, - `Z data type should be ${CATEGORICAL} as there's only one value for z and it's an integer` - ); - - t.equal( - scatterplot.get('wDataType'), - CATEGORICAL, - `W data type should be ${CATEGORICAL} as there's only one value for w and it's an integer` - ); - - await scatterplot.draw([ - [0, 0, 0, 0], - [0, 0, 1, 1], - ]); - - t.equal( - scatterplot.get('zDataType'), - CATEGORICAL, - `Z data type should be ${CATEGORICAL} as the two z values are integers` - ); - - t.equal( - scatterplot.get('wDataType'), - CATEGORICAL, - `W data type should be ${CATEGORICAL} as the two w values are integers` - ); - - await scatterplot.draw([ - [0, 0, 0, 0], - [0, 0, 1, 1], - [0, 0, 10, 10], - ]); - - t.equal( - scatterplot.get('zDataType'), - CATEGORICAL, - `Z data type should be ${CATEGORICAL} as all z values are integers` - ); - - t.equal( - scatterplot.get('wDataType'), - CATEGORICAL, - `W data type should be ${CATEGORICAL} as all w values are integers` - ); - - await scatterplot.draw([[0, 0, 0.5, 0.5]]); - - t.equal( - scatterplot.get('zDataType'), - CONTINUOUS, - `Z data type should be ${CONTINUOUS} as the z value is a float` - ); - - t.equal( - scatterplot.get('wDataType'), - CONTINUOUS, - `W data type should be ${CONTINUOUS} as the w value is a float` - ); - - await scatterplot.draw([ - [0, 0, 0, 0], - [0, 0, 0.5, 0.5], - [0, 0, 1, 1], - ]); - - t.equal( - scatterplot.get('zDataType'), - CONTINUOUS, - `Z data type should be ${CONTINUOUS} as one z value is a float` - ); - - t.equal( - scatterplot.get('wDataType'), - CONTINUOUS, - `W data type should be ${CONTINUOUS} as one w value is a float` - ); - - await scatterplot.draw( - [ - [0, 0, 0, 0], - [0, 0, 1, 1], - ], - { zDataType: CONTINUOUS, wDataType: CONTINUOUS } - ); - - t.equal( - scatterplot.get('zDataType'), - CONTINUOUS, - `Z data type should be ${CONTINUOUS} as we manually specified the z data type` - ); - - t.equal( - scatterplot.get('wDataType'), - CONTINUOUS, - `W data type should be ${CONTINUOUS} as we manually specified the w data type` - ); - - await scatterplot.draw([[0, 0, 0.5, 1]]); - - t.equal( - scatterplot.get('zDataType'), - CONTINUOUS, - `Z data type should be ${CONTINUOUS} as the z value is a float` - ); - - t.equal( - scatterplot.get('wDataType'), - CATEGORICAL, - `W data type should be ${CATEGORICAL} as the w value is an integer` - ); - - await scatterplot.draw([[0, 0, 1, 0.5]]); - - t.equal( - scatterplot.get('zDataType'), - CATEGORICAL, - `Z data type should be ${CATEGORICAL} as the z value is an integer` - ); - - t.equal( - scatterplot.get('wDataType'), - CONTINUOUS, - `W data type should be ${CONTINUOUS} as the w value is a float` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'test drawing point connections via `showLineConnections`', - catchError(async (t) => { - const scatterplot = createScatterplot({ - canvas: createCanvas(200, 200), - width: 200, - height: 200, - showPointConnections: true, - }); - - let numConnectionsDraws = 0; - scatterplot.subscribe('pointConnectionsDraw', () => { - ++numConnectionsDraws; - }); - - await scatterplot.draw( - new Array(10) - .fill() - .map((_, i) => [ - -1 + (i / 6) * 2, // x - -1 + Math.random() * 2, // y - i, // category - 1, // group - 0, // line group - ]) - ); - await wait(0); - - t.equal(numConnectionsDraws, 1, 'should have drawn the connections once'); - - await scatterplot.draw( - new Array(10) - .fill() - .map((e, i) => [ - -1 + (i / 6) * 2, - -1 + Math.random() * 2, - i, - 1, - i % 5, - ]) - ); - await wait(0); - - t.equal(numConnectionsDraws, 2, 'should have drawn the connections once'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'draw(), clear(), publish("select")', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - await scatterplot.draw(points); - // The second draw call should not block the drawing of the points! - // This test is related to a previous issue caused by `drawRaf` as `withRaf` - // overwrites previous arguments. While that is normally expected, this - // should not overwrite the points from above - await scatterplot.draw(); - - let selectedPoints = []; - const selectHandler = ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }; - const deselectHandler = () => { - selectedPoints = []; - }; - // Event names should be case insensitive. Let's test it - scatterplot.subscribe('sElEcT', selectHandler); - scatterplot.subscribe('deselect', deselectHandler); - - // Test single selection via mouse clicks - canvas.dispatchEvent( - createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) - ); - canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); - - await wait(0); - - t.equal(selectedPoints.length, 1, 'should have selected one point'); - t.equal(selectedPoints[0], 0, 'should have selected the first point'); - - // Test deselection - canvas.dispatchEvent(createMouseEvent('dblclick', hdim, hdim)); - - await wait(0); - - t.equal(selectedPoints.length, 0, 'should have deselected one point'); - - // Test that mousedown + mousemove + click is not interpreted as a click when - // the cursor moved more than `DEFAULT_LASSO_MIN_DIST` in between mousedown and - // mouseup - canvas.dispatchEvent( - createMouseEvent('mousedown', hdim - DEFAULT_LASSO_MIN_DIST, hdim, { - buttons: 1, - }) - ); - canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); - - await wait(0); - - t.equal(selectedPoints.length, 0, 'should *not* have selected one point'); - - // Test that clearing the points works. The selection that worked previously - // should not work anymore - scatterplot.clear(); - window.dispatchEvent( - createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) - ); - canvas.dispatchEvent(createMouseEvent('click', hdim, hdim)); - - await wait(0); - - t.equal(selectedPoints.length, 0, 'should *not* have selected one point'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'lasso selection (with events: select, lassoStart, lassoExtend, and lassoEnd)', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - await scatterplot.draw(points); - - let selectedPoints = []; - const selectHandler = ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }; - const deselectHandler = () => { - selectedPoints = []; - }; - scatterplot.subscribe('select', selectHandler); - scatterplot.subscribe('deselect', deselectHandler); - - let lassoStartCount = 0; - let lassoExtendCount = 0; - let lassoEndCount = 0; - let lassoEndCoordinates = []; - scatterplot.subscribe('lassoStart', () => ++lassoStartCount); - scatterplot.subscribe('lassoExtend', () => ++lassoExtendCount); - scatterplot.subscribe('lassoEnd', ({ coordinates }) => { - ++lassoEndCount; - lassoEndCoordinates = coordinates; - }); - - const [lassoKey] = Object.entries(scatterplot.get('keyMap')).find( - (mapping) => mapping[1] === KEY_ACTION_LASSO - ); - - // Test multi selections via mousedown + mousemove - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 1.125, hdim, { - [`${lassoKey}Key`]: true, - buttons: 1, - }) - ); - - // Needed to first digest the mousedown event - await wait(0); - - const mousePositions = [ - [dim * 1.125, hdim], - [hdim, -dim * 0.125], - [-dim * 0.125, -dim * 0.125], - [-dim * 0.125, dim * 0.125], - [0, dim * 0.9], - [dim * 0.1, dim * 0.9], - [dim * 0.1, dim * 1.125], - [dim * 1.125, dim * 1.125], - ]; - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - window.dispatchEvent(createMouseEvent('mouseup')); - - await wait(0); - - t.equal(selectedPoints.length, 3, 'should have selected 3 points'); - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have selected the first, third, and fifth point' - ); - - t.equal(lassoStartCount, 1, 'should have triggered lassoStart once'); - t.equal( - lassoExtendCount, - mousePositions.length, - 'should have triggered lassoExtend once' - ); - t.equal( - lassoEndCoordinates.length, - mousePositions.length, - `should have created a lasso with ${mousePositions.length} points` - ); - t.equal(lassoEndCount, 1, 'should have triggered lassoEnd once'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'disable lasso selection', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - keyMap: {}, - }); - - await scatterplot.draw([[0, 0]]); - - let selectedPoints = []; - scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }); - - let lassoStartCount = 0; - scatterplot.subscribe('lassoStart', () => ++lassoStartCount); - - t.equal( - 0, - Object.entries(scatterplot.get('keyMap')).length, - 'KeyMap should be unset' - ); - - // Test multi selections via mousedown + mousemove - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 1.125, hdim, { - buttons: 1, - altKey: true, - ctrlKey: true, - metaKey: true, - shiftKey: true, - }) - ); - - // Needed to first digest the mousedown event - await wait(0); - - const mousePositions = [ - [dim * 1.125, hdim], - [hdim, -dim * 0.125], - [-dim * 0.125, -dim * 0.125], - [-dim * 0.125, dim * 0.125], - [0, dim * 0.9], - [dim * 0.1, dim * 0.9], - [dim * 0.1, dim * 1.125], - [dim * 1.125, dim * 1.125], - ]; - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - window.dispatchEvent(createMouseEvent('mouseup')); - - await wait(0); - - t.equal( - lassoStartCount, - 0, - 'should have not triggered lassoStart at all' - ); - - t.equal(selectedPoints.length, 0, 'should have not selected any points'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'test lasso selection via the initiator', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - lassoInitiator: false, - }); - - await scatterplot.draw([ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]); - - let selectedPoints = []; - scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }); - - const lassoIniatorElement = scatterplot.get('lassoInitiatorElement'); - - t.ok( - scatterplot.get('lassoInitiator') === false, - 'lasso initiator should be inactive' - ); - t.ok( - lassoIniatorElement.id.startsWith('lasso-initiator'), - 'lasso initiator element should exist' - ); - t.equal( - scatterplot.get('lassoInitiatorParentElement'), - document.body, - 'lasso initiator parent element should be the document body' - ); - - canvas.dispatchEvent(createMouseEvent('click', dim * 1.125, hdim)); - - // We need to wait for the click delay and some extra milliseconds for - // the circle to appear - await wait(SINGLE_CLICK_DELAY + 50); - - lassoIniatorElement.dispatchEvent( - createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) - ); - await wait(0); - - const mousePositions = [ - [dim * 1.125, hdim], - [hdim, -dim * 0.125], - [-dim * 0.125, -dim * 0.125], - [-dim * 0.125, dim * 0.125], - [0, dim * 0.9], - [dim * 0.1, dim * 0.9], - [dim * 0.1, dim * 1.125], - [dim * 1.125, dim * 1.125], - ]; - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - window.dispatchEvent(createMouseEvent('mouseup')); - - await wait(0); - - t.deepEqual(selectedPoints, [], 'should have not selected anything'); - - scatterplot.set({ lassoInitiator: true }); - - await wait(0); - - t.ok( - scatterplot.get('lassoInitiator'), - 'lasso initiator should be active' - ); - - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) - ); - await wait(0); - canvas.dispatchEvent(createMouseEvent('click', dim * 1.125, hdim)); - - // We need to wait for the click delay and some extra milliseconds for - // the circle to appear - await wait(SINGLE_CLICK_DELAY + 50); - - lassoIniatorElement.dispatchEvent( - createMouseEvent('mousedown', dim * 1.125, hdim, { buttons: 1 }) - ); - await wait(0); - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - window.dispatchEvent(createMouseEvent('mouseup')); - - await wait(0); - - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have selected the first, third, and fifth point' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'test rotation', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - }); - - await scatterplot.draw([[0, 0]]); - - const initialRotation = scatterplot.get('cameraRotation'); - t.equal(initialRotation, 0, 'view should not be rotated on init'); - - let rotation; - const viewHandler = ({ camera }) => { - rotation = camera.rotation; - }; - scatterplot.subscribe('view', viewHandler); - - let [rotateKey] = Object.entries(scatterplot.get('keyMap')).find( - (mapping) => mapping[1] === KEY_ACTION_ROTATE - ); - - // Test rotation via keydown + mousedown + mousemove + keydown - window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent( - createKeyboardEvent('keydown', capitalize(rotateKey), { - [`${rotateKey}Key`]: true, - }) - ); - await wait(0); - - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 0.75, hdim, { - [`${rotateKey}Key`]: true, - buttons: 1, - }) - ); - - await wait(0); - - const mousePositions = [ - [dim * 0.75, hdim], - [dim * 0.75, hdim * 0.5], - ]; - - let whenDrawn = new Promise((resolve) => { - scatterplot.subscribe('draw', resolve, 1); - }); - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - // We need to ensure that the camera's tick() function was called before - // we release the mouse via the mouseup event - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent(createMouseEvent('mouseup')); - window.dispatchEvent( - createKeyboardEvent('keyup', capitalize(rotateKey), { - [`${rotateKey}Key`]: true, - }) - ); - - await whenDrawn; - await wait(10); - - t.ok( - initialRotation !== rotation && Number.isFinite(rotation), - 'view should have been rotated' - ); - - const lastRotation = rotation; - const oldRotateKey = rotateKey; - - rotateKey = 'shift'; - scatterplot.set({ keyMap: { [rotateKey]: 'rotate' } }); - - // Needed to first digest the keyMap change - await wait(10); - - // Test rotation via mousedown + mousemove + keydown - window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent( - createKeyboardEvent('keydown', capitalize(oldRotateKey), { - [`${oldRotateKey}Key`]: true, - }) - ); - await wait(0); - - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 0.75, hdim, { - [`${oldRotateKey}Key`]: true, - buttons: 1, - }) - ); - - // Needed to first digest the mousedown event - await wait(10); - - whenDrawn = new Promise((resolve) => { - scatterplot.subscribe('draw', resolve, 1); - }); - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - // We need to ensure that the camera's tick() function was called before - // we release the mouse via the mouseup event - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent(createMouseEvent('mouseup')); - window.dispatchEvent( - createKeyboardEvent('keyup', capitalize(oldRotateKey), { - [`${oldRotateKey}Key`]: true, - }) - ); - - await whenDrawn; - await wait(10); - - t.equal( - lastRotation, - rotation, - 'view should have not been rotated via the old modifier key' - ); - - // Test rotation via mousedown + mousemove + keydown - window.dispatchEvent(createMouseEvent('mousemove', dim * 0.75, hdim)); - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent( - createKeyboardEvent('keydown', capitalize(rotateKey), { - [`${rotateKey}Key`]: true, - }) - ); - await wait(0); - - canvas.dispatchEvent( - createMouseEvent('mousedown', dim * 0.75, hdim, { - [`${rotateKey}Key`]: true, - buttons: 1, - }) - ); - - // Needed to first digest the mousedown event - await wait(10); - - whenDrawn = new Promise((resolve) => { - scatterplot.subscribe('draw', resolve, 1); - }); - - await asyncForEach(mousePositions, async (mousePosition) => { - window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); - await wait(DEFAULT_LASSO_MIN_DELAY + 5); - }); - - // We need to ensure that the camera's tick() function was called before - // we release the mouse via the mouseup event - await nextAnimationFrame(); - await wait(0); - - window.dispatchEvent(createMouseEvent('mouseup')); - window.dispatchEvent( - createKeyboardEvent('keyup', capitalize(rotateKey), { - [`${rotateKey}Key`]: true, - }) - ); - - await whenDrawn; - await wait(10); - - t.ok( - lastRotation !== rotation, - 'view should have been rotated via the new modifier key' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'point hover with publish("pointover") and publish("pointout")', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - await scatterplot.draw(points); - - let hoveredPoint = null; - const pointoverHandler = (point) => { - hoveredPoint = point; - }; - const pointoutHandler = () => { - hoveredPoint = null; - }; - scatterplot.subscribe('pointover', pointoverHandler); - scatterplot.subscribe('pointout', pointoutHandler); - - // Test single selection via mouse clicks - canvas.dispatchEvent(createMouseEvent('mouseenter', hdim, hdim)); - await wait(250); - window.dispatchEvent(createMouseEvent('mousemove', hdim, hdim)); - await wait(250); - - t.equal(hoveredPoint, 0, 'should be hovering point 0 (in the middle)'); - - // Test deselection - window.dispatchEvent(createMouseEvent('mousemove', hdim / 2, hdim)); - - await wait(0); - - t.equal(hoveredPoint, null, 'should not be hovering any point'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'publish("view")', - catchError(async (t) => { - const dim = 200; - const hdim = dim / 2; - const canvas = createCanvas(dim, dim); - const xScale = scaleLinear().domain([-5, 5]); - const yScale = scaleLinear().domain([0, 0.5]); - - const scatterplot = createScatterplot({ - canvas, - width: dim, - height: dim, - xScale, - yScale, - }); - await scatterplot.draw([[0, 0]]); - - const predictedView = mat4.fromTranslation([], [-1, 0, 0]); - - let currentView; - let currentCamera; - const viewHandler = ({ camera, view }) => { - currentCamera = camera; - currentView = view; - }; - scatterplot.subscribe('view', viewHandler); - - window.dispatchEvent(createMouseEvent('mouseup', hdim, hdim)); - window.dispatchEvent(createMouseEvent('mousemove', hdim, hdim)); - await nextAnimationFrame(); - await wait(50); - - canvas.dispatchEvent( - createMouseEvent('mousedown', hdim, hdim, { buttons: 1 }) - ); - await nextAnimationFrame(); - await wait(50); - window.dispatchEvent(createMouseEvent('mousemove', 0, hdim)); - await nextAnimationFrame(); - await wait(50); - - t.deepEqual( - Array.from(currentView), - predictedView, - 'should have published the translated view' - ); - - t.ok(currentCamera, 'should have published the camera'); - - t.deepEqual(xScale.domain(), [0, 10], 'should have updated the xScale'); - - t.deepEqual(yScale.domain(), [0, 0.5], 'should have updated the yScale'); - - scatterplot.destroy(); - }) - ); - - /* ---------------------------- Other Methods ----------------------------- */ - - await t2.test( - 'draw() with transition', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - let numDrawCalls = 0; - let numTransitionStartCalls = 0; - let numTransitionEndCalls = 0; - - scatterplot.subscribe('draw', () => numDrawCalls++); - scatterplot.subscribe('transitionStart', () => numTransitionStartCalls++); - scatterplot.subscribe('transitionEnd', () => numTransitionEndCalls++); - - await scatterplot.draw([[0, 0]]); - - t.equal(numDrawCalls, 1, 'should have one draw call'); - - const t0 = performance.now(); - const transitionDuration = 250; - - await scatterplot.draw([[1, 1]], { - transition: true, - transitionDuration, - }); - - t.equal( - numTransitionStartCalls, - 1, - 'transition should have started once' - ); - t.equal(numTransitionEndCalls, 1, 'transition should have ended once'); - t.ok( - performance.now() - t0 >= transitionDuration, - `transition should have taken ${transitionDuration}msec or a bit longer` - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'draw() with preventFilterReset', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const filteredPoints = [0, 1, 2]; - - const points = [ - [0, 0], - [0.9, 0.9], - [0.9, -0.9], - [-0.9, -0.9], - [-0.9, 0.9], - ]; - - await scatterplot.draw(points); - - await scatterplot.filter(filteredPoints); - await wait(0); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), filteredPoints), - `only points ${filteredPoints} should be filtered in` - ); - - let img = scatterplot.export(); - - t.equal( - getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height), - 0, - 'The left side of the image should be empty as points #3 and #4 are filtered out' - ); - - t.ok( - getPixelSum(img, Math.ceil(img.width / 3), img.width, 0, img.height) > - 0, - 'The right side of the image should *not* be empty as points #0 to #2 are filtered in' - ); - - await scatterplot.draw([...points], { preventFilterReset: true }); - - t.equal( - scatterplot.get('isPointsFiltered'), - true, - '`isPointsFiltered` should be `true` as draw has been invoked with preventFilterReset' - ); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), filteredPoints), - 'the filtered points should be the same as before' - ); - - img = scatterplot.export(); - - t.equal( - getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height), - 0, - 'The left side of the image should be empty as points #3 and #4 are filtered out' - ); - - t.ok( - getPixelSum(img, Math.ceil(img.width / 3), img.width, 0, img.height) > - 0, - 'The right side of the image should *not* be empty as points #0 to #2 are filtered in' - ); - - await scatterplot.draw([...points, [0.5, 0.5]], { - preventFilterReset: true, - }); - - t.equal( - scatterplot.get('isPointsFiltered'), - false, - '`isPointsFiltered` should be `false` as draw has been invoked with different number of points' - ); - - t.equal( - scatterplot.get('filteredPoints').length, - points.length + 1, - 'the filtered points should be reset as draw has been invoked with different number of points' - ); - - img = scatterplot.export(); - - t.ok( - getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height) > 0, - 'The left side of the image should *not* be empty as the filter was reset' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'select()', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - - await scatterplot.draw(points); - - let selectedPoints = []; - const selectHandler = ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }; - const deselectHandler = () => { - selectedPoints = []; - }; - scatterplot.subscribe('select', selectHandler); - scatterplot.subscribe('deselect', deselectHandler); - - scatterplot.select([0, 2, 4]); - - await wait(0); - - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have selected point 0, 2, and 4' - ); - - t.deepEqual( - scatterplot.get('selectedPoints'), - [0, 2, 4], - 'should be able to retrieve the selected points' - ); - - scatterplot.deselect(); - - await wait(0); - - t.equal(selectedPoints.length, 0, 'should have deselected all points'); - - scatterplot.select([0, 2, 4]); - - await wait(0); - - t.deepEqual( - scatterplot.get('selectedPoints'), - [0, 2, 4], - 'should have selected three points again' - ); - - scatterplot.select([]); - - await wait(0); - - t.equal( - selectedPoints.length, - 0, - 'should have deselected all points via `select([])`' - ); - - scatterplot.select([0, 2, 4], { preventEvent: true }); - - await wait(0); - - t.equal( - selectedPoints.length + scatterplot.get('selectedPoints').length, - 3, - 'should have silently selected three points' - ); - - scatterplot.deselect(); - scatterplot.select([0, 2, 4]); - scatterplot.deselect({ preventEvent: true }); - - await wait(0); - - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have silently deselected points' - ); - - scatterplot.select(2); - - await wait(0); - - t.deepEqual( - selectedPoints, - [2], - 'should allow single point index selection' - ); - - scatterplot.select(-1); - - await wait(0); - - t.deepEqual( - selectedPoints, - [], - 'should have not selected any point because -1 is invalid' - ); - - scatterplot.select([0, -1, 2, 4, 6]); - - await wait(0); - - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have filtered out invalid point selections' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'hover() with columnar data', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const data = { - x: [0, 1, 1, -1, -1], - y: [0, 1, -1, -1, 1], - }; - - await scatterplot.draw(data); - - let hovering; - const pointoverHandler = (newHovering) => { - hovering = newHovering; - }; - const pointoutHandler = () => { - hovering = undefined; - }; - scatterplot.subscribe('pointover', pointoverHandler); - scatterplot.subscribe('pointout', pointoutHandler); - - scatterplot.hover(0); - - await wait(0); - - t.equal(hovering, 0, 'should be hovering point 0'); - - scatterplot.hover(); - - await wait(0); - - t.equal(hovering, undefined, 'should have stopped hovering point 0'); - - scatterplot.hover(2, { preventEvent: true }); - - await wait(0); - - t.equal(hovering, undefined, 'should be silently hovering point 2'); - - scatterplot.hover(4); - scatterplot.hover(undefined, { preventEvent: true }); - - await wait(0); - - t.deepEqual(hovering, 4, 'should have silently stopped hovering point 4'); - - scatterplot.hover(-1); - - await wait(0); - - // Point 4 should still be registered as being hovered - t.deepEqual(hovering, 4, 'should not be hovering an invalid point'); - - scatterplot.hover(6); - - await wait(0); - - // Point 4 should still be registered as being hovered - t.deepEqual(hovering, 4, 'should not be hovering an invalid point'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'filter()', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - - await scatterplot.draw(points); - - t.deepEqual( - scatterplot.get('isPointsFiltered'), - false, - '`isPointsFiltered` should be `false` as points have not been filtered' - ); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]), - 'all points should be filtered in' - ); - - let filteredPoints = []; - const filterHandler = ({ points: newFilteredPoints }) => { - filteredPoints = [...newFilteredPoints]; - }; - const unfilterHandler = () => { - filteredPoints = scatterplot.get('filteredPoints'); - }; - scatterplot.subscribe('filter', filterHandler); - scatterplot.subscribe('unfilter', unfilterHandler); - - let hoveredPoint; - const pointOverHandler = (pointIdx) => { - hoveredPoint = pointIdx; - }; - const pointOutHandler = () => { - hoveredPoint = undefined; - }; - scatterplot.subscribe('pointover', pointOverHandler); - scatterplot.subscribe('pointout', pointOutHandler); - - await scatterplot.filter([1, 3]); - await wait(0); - - t.ok( - isSameElements(filteredPoints, [1, 3]), - 'should have filtered down to points 1 and 3' - ); - - t.deepEqual( - scatterplot.get('isPointsFiltered'), - true, - '`isPointsFiltered` should be `true` as points have been filtered' - ); - - t.ok( - isSameElements(scatterplot.get('pointsInView'), [1, 3]), - 'should have two points (1 and 3) in view' - ); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), [1, 3]), - 'should be able to retrieve the filtered points' - ); - - scatterplot.hover(1); - await wait(0); - - t.equal( - hoveredPoint, - 1, - 'should be able to hover a point that is filtered in' - ); - - scatterplot.hover(2); - await wait(0); - - t.equal( - hoveredPoint, - undefined, - 'should not be able to hover a point that is filtered out' - ); - - let selectedPoints = []; - const selectHandler = ({ points: newSelectedPoints }) => { - selectedPoints = [...newSelectedPoints]; - }; - const deselectHandler = () => { - selectedPoints = []; - }; - scatterplot.subscribe('select', selectHandler); - scatterplot.subscribe('deselect', deselectHandler); - - scatterplot.select([0, 2, 4]); - - await wait(0); - - t.equal( - selectedPoints.length, - 0, - 'should not have selected points 0, 2, and 4 because they are filtered out' - ); - - await scatterplot.filter([]); - - await wait(0); - - t.deepEqual(filteredPoints, [], 'should have filtered out all points'); - - t.equal( - scatterplot.get('filteredPoints').length, - 0, - 'should retrieve an empty array of filtered points' - ); - - scatterplot.select([0, 1, 2, 3, 4]); - - await wait(0); - - t.equal( - selectedPoints.length, - 0, - 'should not be able to selected any points because all are filtered out' - ); - - await scatterplot.unfilter(); - await wait(0); - - t.ok( - isSameElements(filteredPoints, [0, 1, 2, 3, 4]), - 'should have unfiltered the points' - ); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]), - 'should be able to retrieve the filtered points' - ); - - t.deepEqual( - scatterplot.get('isPointsFiltered'), - false, - '`isPointsFiltered` should be `false` as we reset the point filter' - ); - - scatterplot.select([0, 2, 4]); - - await wait(0); - - t.deepEqual( - selectedPoints, - [0, 2, 4], - 'should have selected points 0, 2, and 4 because we unfiltered points first' - ); - - await scatterplot.filter([1, 3], { preventEvent: true }); - await wait(0); - - t.ok( - filteredPoints.length === 5 && - scatterplot.get('pointsInView').length === 2, - 'should have silently filtered down to two points' - ); - - await scatterplot.filter([0, 1, 3]); - await scatterplot.unfilter({ preventEvent: true }); - await wait(0); - - t.ok( - filteredPoints.length === 3 && - scatterplot.get('pointsInView').length === 5, - 'should have silently unfiltered points' - ); - - await scatterplot.filter(2); - await wait(0); - - t.ok( - filteredPoints.length === 1 && - filteredPoints[0] === 2 && - scatterplot.get('pointsInView').length === 1, - 'should allow filtering down to a single point' - ); - - await scatterplot.filter(-1); - await wait(0); - - t.equal( - filteredPoints.length, - 0, - 'should have not filter down to any point because -1 is an invalid point index' - ); - - const pointsToBeFiltered = [0, -1, 2, 4, 6]; - - await scatterplot.filter(pointsToBeFiltered); - await wait(0); - - t.ok( - isSameElements(filteredPoints, [0, 2, 4]), - 'should have filtered down to valid points (0, 2, and 4) only' - ); - - // We're testing this due to the following bug where we accidentically - // spliced of from the input argument, which we should never do. - // @see https://github.com/flekschas/regl-scatterplot/issues/197 - t.equal( - pointsToBeFiltered.length, - 5, - 'should have not altered `pointsToBeFiltered`' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - '`filter() point order consistency`', - catchError(async (t) => { - const scatterplot = createScatterplot({ - canvas: createCanvas(50, 50), - width: 50, - height: 50, - pointColor: ['#FF0000', '#00FF00', '#0000FF'], - pointSize: 50, - opacity: 1, - colorBy: "valueA" - }); - - const dpr = window.devicePixelRatio; - const middlePixel = (50 * dpr * 25 * dpr + 25 * dpr) * 4; - - await scatterplot.draw({ - x: [0, 0, 0], - y: [0, 0, 0], - valueA: [0, 1, 2] - }); - - await wait(25); - - let image = scatterplot.export(); - - t.equal( - Array.from(image.data.slice(middlePixel, middlePixel + 4)), - [0, 0, 255, 255], - 'the center pixel should be blue' - ); - - await scatterplot.filter([1, 0]); - - await wait(25); - - image = scatterplot.export(); - - t.equal( - Array.from(image.data.slice(middlePixel, middlePixel + 4)), - [0, 255, 0, 255], - 'the center pixel should be green' - ); - - await scatterplot.unfilter(); - await scatterplot.filter([0, 1]); - - await wait(25); - - image = scatterplot.export(); - - t.equal( - Array.from(image.data.slice(middlePixel, middlePixel + 4)), - [0, 255, 0, 255], - 'the center pixel should be green' - ); - }) - ); - - await t2.test( - 'test hover, select, and filter options of `draw()`', - catchError(async (t) => { - const scatterplot = createScatterplot({ - canvas: createCanvas(200, 200), - width: 200, - height: 200, - }); - - const points = [ - [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], - ]; - - await scatterplot.draw(points, { - hover: 0, - select: [1, 2], - filter: [0, 2, 3], - }); - - await wait(50); - - t.equal( - scatterplot.get('hoveredPoint'), - 0, - 'should be hovering the first point' - ); - - t.equal( - scatterplot.get('selectedPoints'), - [2], - 'should have selected the third point' - ); - - t.ok( - isSameElements(scatterplot.get('filteredPoints'), [0, 2, 3]), - 'should have filtered down to points 0, 2, and 3' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'zooming with transition', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - const camera = scatterplot.get('camera'); - - let numDrawCalls = 0; - let numTransitionStartCalls = 0; - let numTransitionEndCalls = 0; - - scatterplot.subscribe('draw', () => numDrawCalls++); - scatterplot.subscribe('transitionStart', () => numTransitionStartCalls++); - scatterplot.subscribe('transitionEnd', () => numTransitionEndCalls++); - - await scatterplot.draw([ - [-1, 1], - [1, 1], - [0, 0], - [-1, -1], - [1, -1], - ]); - - t.equal(numDrawCalls, 1, 'should have one draw call'); - - const t0 = performance.now(); - const transitionDuration = 50; - - await scatterplot.zoomToPoints([1, 2], { - transition: true, - transitionDuration, - }); - - t.equal( - numTransitionStartCalls, - 1, - 'transition should have started once' - ); - t.equal(numTransitionEndCalls, 1, 'transition should have ended once'); - t.ok( - performance.now() - t0 >= transitionDuration, - `transition should have taken ${transitionDuration}msec or a bit longer` - ); - t.ok( - camera.target[0] - 0.5 <= 1e-8, - 'camera should target the top-right corner' - ); - t.ok( - camera.target[1] - 0.5 <= 1e-8, - 'camera should target the top-right corner' - ); - - await scatterplot.zoomToOrigin({ transition: true, transitionDuration }); - - t.equal( - numTransitionStartCalls, - 2, - 'transition should have started twice' - ); - t.equal(numTransitionEndCalls, 2, 'transition should have ended twice'); - t.ok(camera.target[0] <= 1e-8, 'camera should target the origin'); - t.ok(camera.target[1] <= 1e-8, 'camera should target the origin'); - - await scatterplot.zoomToLocation([-0.5, -0.5], 0.1, { - transition: true, - transitionDuration, - }); - - t.equal( - numTransitionStartCalls, - 3, - 'transition should have started three times' - ); - t.equal( - numTransitionEndCalls, - 3, - 'transition should have ended three times' - ); - t.ok( - camera.target[0] + 0.1 <= 1e-8, - 'camera should target the bottom-left corner' - ); - t.ok( - camera.target[1] + 0.1 <= 1e-8, - 'camera should target the bottom-left corner' - ); - t.ok( - camera.distance[0] - 0.1 <= 1e-8, - 'camera distance should be close to 0.1' - ); - - await scatterplot.zoomToArea( - { x: 0, y: -1, width: 1, height: 1 }, - { transition: true, transitionDuration } - ); - - t.equal( - numTransitionStartCalls, - 4, - 'transition should have started four times' - ); - t.equal( - numTransitionEndCalls, - 4, - 'transition should have ended four times' - ); - t.ok( - camera.target[0] - 0.5 <= 1e-8, - 'camera should target the bottom-right corner' - ); - t.ok( - camera.target[1] + 0.5 <= 1e-8, - 'camera should target the bottom-right corner' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'zoomToPoints() before point were drawn should fail', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - try { - await scatterplot.zoomToPoints([1, 2]); - t.fail('zoomToPoints() should have thrown an error'); - } catch (e) { - t.equal(e.message, ERROR_POINTS_NOT_DRAWN); - } - scatterplot.destroy(); - }) - ); - - await t2.test( - 'zoomToArea() with non-standard aspect ratio', - catchError(async (t) => { - const dims = [ - [200, 200], - [200, 100], - [100, 200], - [333, 123], - ]; - const aspectRatios = [1, 2, 0.3, 5 / 3]; - - const xStart = -8 / 6; - const xWidth = 5 + 8 / 6; - const yStart = -1 / 3; - const yWidth = 2 / 3; - - const points = { - x: Array.from({ length: 500 }, (_, i) => xStart + (i / 499) * xWidth), - y: Array.from({ length: 500 }, (_, i) => yStart + (i / 499) * yWidth), - }; - - const rect = { - x: xStart - EPS, - width: xWidth + EPS * 2, - y: yStart - EPS, - height: yWidth + EPS * 2, - }; - - for (const [width, height] of dims) { - for (const aspectRatio of aspectRatios) { - const scatterplot = createScatterplot({ - canvas: createCanvas(width, height), - aspectRatio, - }); - - // eslint-disable-next-line no-await-in-loop - await scatterplot.draw(points); - // eslint-disable-next-line no-await-in-loop - await scatterplot.zoomToArea(rect); - - t.equal( - scatterplot.get('pointsInView').length, - 500, - 'should show all points' - ); - - t.ok( - Math.abs(scatterplot.getScreenPosition(0)[0]) < 0.01, - 'first point should be close to zero' - ); - - t.ok( - Math.abs(scatterplot.getScreenPosition(499)[0] - width) < 0.01, - `last point should be close to ${width}` - ); - - scatterplot.destroy(); - } - } - }) - ); - - await t2.test( - 'getScreenPosition()', - catchError(async (t) => { - const scatterplot = createScatterplot({ - canvas: createCanvas(100, 100), - width: 100, - height: 100, - }); - - try { - scatterplot.getScreenPosition(0); - t.fail('getScreenPosition() should have thrown an error'); - } catch (e) { - t.equal(e.message, ERROR_POINTS_NOT_DRAWN); - } - - await scatterplot.draw([ - [-1, -1], - [0, 0], - [1, 1], - ]); - - t.equal( - scatterplot.getScreenPosition(0), - [0, 100], - 'First point should be drawn at [0, 100]' - ); - t.equal( - scatterplot.getScreenPosition(1), - [50, 50], - 'Second point should be drawn at [50, 50]' - ); - t.equal( - scatterplot.getScreenPosition(2), - [100, 0], - 'Third point should be drawn at [100, 0]' - ); - - // A forth point does not exist as we only drew three - t.equal( - scatterplot.getScreenPosition(3), - undefined, - 'Forth point does not exist' - ); - - await scatterplot.zoomToPoints([0]); - t.equal( - scatterplot.getScreenPosition(0), - [50, 50], - 'After zooming, the first point should be drawn at [50, 50]' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'isSupported', - catchError((t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - const renderer = scatterplot.get('renderer'); - - t.ok( - Object.prototype.hasOwnProperty.call(scatterplot, 'isSupported'), - 'scatter plot instance should have `isSupported` property' - ); - - t.ok( - Object.prototype.hasOwnProperty.call(renderer, 'isSupported'), - 'renderer instance should have `isSupported` property' - ); - - t.ok( - scatterplot.isSupported === true || scatterplot.isSupported === false, - '`isSupported` should return a Boolean value' - ); - - t.ok( - renderer.isSupported === true || renderer.isSupported === false, - '`isSupported` should return a Boolean value' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'test "drawing" event', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - let numDrawCalls = 0; - let numDrawingCalls = 0; - - scatterplot.subscribe('draw', () => ++numDrawCalls); - scatterplot.subscribe('drawing', () => ++numDrawingCalls); - - await new Promise((resolve) => { - scatterplot.subscribe('init', resolve); - }); - - const isDrawn = scatterplot.draw([[-1, 1]]); - await nextAnimationFrame(); - - t.equal(numDrawCalls, 0, 'should not have registered any "draw" event'); - t.equal(numDrawingCalls, 1, 'should have registered one "drawing" event'); - - await isDrawn; - - t.equal(numDrawCalls, 1, 'should have registered one "draw" event'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'spatial index', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const x = [-1, 1, 0, -1, 1]; - const y = [1, 1, 0, -1, -1]; - const z1 = [0.2, 0.4, 0.6, 0.8, 1]; - const z2 = [1, 0.8, 0.6, 0.4, 0.2]; - - await scatterplot.draw({ x, y, z: z1 }); - - await scatterplot.zoomToArea( - { - x: -EPS, - y: -EPS, - width: 1 + 2 * EPS, - height: 1 + 2 * EPS, - }, - { log: true } - ); - - t.equal( - scatterplot.get('pointsInView'), - [1, 2], - 'should have points #1 and #2 in the view' - ); - - const spatialIndex = scatterplot.get('spatialIndex'); - - // Because `x` and `y` remain unchanged, we can reuse the spatial index - // from the previous draw call. - await scatterplot.draw({ x, y, z: z2 }, { spatialIndex }); - - await scatterplot.zoomToArea({ - x: -1 - EPS, - y: -1 - EPS, - width: 1 + 2 * EPS, - height: 1 + 2 * EPS, - }); - - // The reused spatial index should work as before - t.equal( - scatterplot.get('pointsInView'), - [2, 3], - 'should have points #2 and #3 in the view' - ); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'drawAnnotations()', - catchError(async (t) => { - const scatterplot = createScatterplot({ - canvas: createCanvas(100, 100), - width: 100, - height: 100, - }); - - await scatterplot.drawAnnotations([ - { x: 0.9, lineColor: [1, 1, 1, 0.1], lineWidth: 1 }, - { y: 0.9, lineColor: [1, 1, 1, 0.1], lineWidth: 1 }, - { - x1: -0.8, - y1: -0.8, - x2: -0.6, - y2: -0.6, - lineColor: [1, 1, 1, 0.25], - lineWidth: 1, - }, - { - x: -0.8, - y: 0.6, - width: 0.2, - height: 0.2, - lineColor: [1, 1, 1, 0.25], - lineWidth: 1, - }, - { - vertices: [ - [0.6, 0.8], - [0.8, 0.8], - [0.8, 0.6], - [0.6, 0.6], - [0.6, 0.8], - ], - lineColor: '#D55E00', - lineWidth: 2, - }, - ]); - - let img = scatterplot.export(); - let w = img.width; - let h = img.height; - const wp = w * 0.1; - const hp = w * 0.1; - - t.ok( - getPixelSum(img, 0, w, 0, hp) > 0, - 'The horizontal line should be drawn' - ); - - t.ok( - getPixelSum(img, w - wp, w, 0, h) > 0, - 'The vertical line should be drawn' - ); - - t.ok( - getPixelSum(img, wp, 2 * wp, h - hp * 2, h - hp) > 0, - 'The bottom left rectangle should be drawn' - ); - - t.ok( - getPixelSum(img, wp, 2 * wp, hp, 2 * hp) > 0, - 'The top left rectangle should be drawn' - ); - - t.ok( - getPixelSum(img, wp - 2 * wp, w - wp, hp, 2 * hp) > 0, - 'The top right polygon should be drawn' - ); - - await scatterplot.clearAnnotations(); - - img = scatterplot.export(); - w = img.width; - h = img.height; - t.ok(getPixelSum(img, 0, w, 0, h) === 0, 'Annotations should be cleared'); - - scatterplot.destroy(); - }) - ); - - await t2.test( - 'pointScaleMode', - catchError(async (t) => { - const dim = 100; - const scatterplot = createScatterplot({ - canvas: createCanvas(dim, dim), - width: dim, - height: dim, - pointSize: 10, - }); - - t.equal( - scatterplot.get('pointScaleMode'), - DEFAULT_POINT_SCALE_MODE, - `The default point scale mode should be ${DEFAULT_POINT_SCALE_MODE}` - ); - - await scatterplot.draw([[0, 0]]); - - const initialImage = scatterplot.export(); - const initialPixelSum = getPixelSum(initialImage, 0, dim, 0, dim); - - t.ok(initialPixelSum > 0, 'The point should be drawn'); - - // Zoom in a bit - await scatterplot.zoomToLocation([0, 0], 0.5); - - const asinhImage = scatterplot.export(); - const asinhPixelSum = getPixelSum(asinhImage, 0, dim, 0, dim); - - t.ok(asinhPixelSum > initialPixelSum, 'The point should be larger'); - - // Zoom back to the origin - await scatterplot.zoomToLocation([0, 0], 1); - - scatterplot.set({ pointScaleMode: 'constant' }); - t.equal( - scatterplot.get('pointScaleMode'), - 'constant', - 'The new point scale mode should be constant' - ); - - // Zoom in a bit - await scatterplot.zoomToLocation([0, 0], 0.5); - - const constantImage = scatterplot.export(); - const constantPixelSum = getPixelSum(constantImage, 0, dim, 0, dim); - - t.ok(constantPixelSum === initialPixelSum, 'The point should be unchanged'); - - // Zoom back to the origin - await scatterplot.zoomToLocation([0, 0], 1); - - scatterplot.set({ pointScaleMode: 'linear' }); - t.equal( - scatterplot.get('pointScaleMode'), - 'linear', - 'The new point scale mode should be linear' - ); - - // Zoom in a bit - await scatterplot.zoomToLocation([0, 0], 0.5); - - const linearImage = scatterplot.export(); - const linearPixelSum = getPixelSum(linearImage, 0, dim, 0, dim); - - t.ok(linearPixelSum > asinhPixelSum, 'The point should be larger'); - - scatterplot.set({ pointScaleMode: 'nonsense' }); - t.equal( - scatterplot.get('pointScaleMode'), - 'asinh', - 'The point scale mode should default to "asinh" for invalid values' - ); - - scatterplot.destroy(); - }) - ); -}); - -/* --------------------------------- Utils ---------------------------------- */ - -test( - 'isValidBBox()', - catchError(async (t) => { - t.equal( - isValidBBox([1, 2, 3, 4]), - true, - 'should recognize valid bounding box' - ); - t.equal( - isValidBBox([1, 2, 1, 4]), - false, - 'should recognize invalid bounding box (x range is zero)' - ); - t.equal( - isValidBBox([1, 2, 3, 2]), - false, - 'should recognize invalid bounding box (y range is zero)' - ); - t.equal( - isValidBBox([1, Infinity, 3, 2]), - false, - 'should recognize invalid bounding box (infinity is not allowed)' - ); - t.equal( - isValidBBox([1, -Infinity, 3, 2]), - false, - 'should recognize invalid bounding box (-infinity is not allowed)' - ); - t.equal( - isValidBBox([1, Number.NaN, 3, 2]), - false, - 'should recognize invalid bounding box (NaN is not allowed)' - ); - }) -); - -test( - 'isNormFloatArray()', - catchError(async (t) => { - t.ok( - !isNormFloatArray([255, 0, 0, 255]), - 'should not be a normalized RGBA value' - ); - t.ok( - !isNormFloatArray([1, 2, 3, 4]), - 'should not be a normalized RGBA value' - ); - - t.ok(isNormFloatArray([0, 0, 0, 0.5]), 'should be a normalized RGBA value'); - t.ok( - isNormFloatArray([0.5, 1, 0.005, 1]), - 'should be a normalized RGBA value' - ); - - // Inconclusive - // [0, 0, 0, 1] could be [0, 0, 0, 1] or [0, 0, 0, 1/255] - t.ok( - isNormFloatArray([0, 0, 0, 1]), - 'should treat inconclusive RGBA value as a normalized RGBA value' - ); - - // [1, 1, 1, 1] could be [1, 1, 1, 1] or [1/255, 1/255, 1/255, 1/255] - t.ok( - isNormFloatArray([1, 1, 1, 1]), - 'should treat inconclusive RGBA value as a normalized RGBA value' - ); - }) -); - -test( - 'toRgba()', - catchError(async (t) => { - t.equal(toRgba('#ff0000'), [255, 0, 0, 255], 'should convert HEX to RGBA'); - - t.equal( - toRgba('#ff0000', true), - [1, 0, 0, 1], - 'should convert HEX to normalized RGBA' - ); - - t.equal( - toRgba([255, 0, 0]), - [255, 0, 0, 255], - 'should convert RGB to RGBA' - ); - - t.equal( - toRgba([255, 0, 0], true), - [1, 0, 0, 1], - 'should convert RGB to normalized RGBA' - ); - - t.equal( - toRgba([1, 0, 0]), - [255, 0, 0, 255], - 'should convert normalized RGB to RGBA' - ); - - t.equal( - toRgba([1, 0, 0], true), - [1, 0, 0, 1], - 'should convert normalized RGB to normalized RGBA' - ); - - t.equal( - toRgba([255, 0, 0, 153]), - [255, 0, 0, 153], - 'should convert RGBA to RGBA' - ); - - t.equal( - toRgba([153, 0, 0, 153], true), - [0.6, 0, 0, 0.6], - 'should convert RGBA to normalized RGBA' - ); - - t.equal( - toRgba([1, 0, 0, 0.6]), - [255, 0, 0, 153], - 'should convert normalized RGBA to RGBA' - ); - - t.equal( - toRgba([0, 0, 0, 0.1], true), - [0, 0, 0, 0.1], - 'should leave normalized RGBA unchanged' - ); - }) -); - -test( - 'checkSupport()', - catchError((t) => { - t.ok( - checkSupport() === true || checkSupport() === false, - 'should return a Boolean value' - ); - }) -); diff --git a/tests/methods.test.js b/tests/methods.test.js new file mode 100644 index 0000000..14fe657 --- /dev/null +++ b/tests/methods.test.js @@ -0,0 +1,898 @@ +import '@babel/polyfill'; +import { assert, expect, test } from 'vitest'; +import { nextAnimationFrame } from '@flekschas/utils'; + +import createScatterplot from '../src'; + +import { + DEFAULT_POINT_SCALE_MODE, + ERROR_POINTS_NOT_DRAWN, +} from '../src/constants'; + +import { + createCanvas, + wait, + isSameElements, + getPixelSum, +} from './utils'; + +const EPS = 1e-7; + +test('draw() with transition', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + let numDrawCalls = 0; + let numTransitionStartCalls = 0; + let numTransitionEndCalls = 0; + + scatterplot.subscribe('draw', () => numDrawCalls++); + scatterplot.subscribe('transitionStart', () => numTransitionStartCalls++); + scatterplot.subscribe('transitionEnd', () => numTransitionEndCalls++); + + await scatterplot.draw([[0, 0]]); + + expect(numDrawCalls).toBe(1); + + const t0 = performance.now(); + const transitionDuration = 250; + + await scatterplot.draw([[1, 1]], { + transition: true, + transitionDuration, + }); + + expect(numTransitionStartCalls).toBe(1); + expect(numTransitionEndCalls).toBe(1); + expect(performance.now() - t0 >= transitionDuration).toBe(true); + + scatterplot.destroy(); +}); + +test('draw() with preventFilterReset', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const filteredPoints = [0, 1, 2]; + + const points = [ + [0, 0], + [0.9, 0.9], + [0.9, -0.9], + [-0.9, -0.9], + [-0.9, 0.9], + ]; + + await scatterplot.draw(points); + + await scatterplot.filter(filteredPoints); + await wait(0); + + expect( + isSameElements(scatterplot.get('filteredPoints'), filteredPoints) + ).toBe(true); + + let img = scatterplot.export(); + + // The left side of the image should be empty as points #3 and #4 are filtered out + expect( + getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height) + ).toBe(0); + + // The right side of the image should *not* be empty as points #0 to #2 are filtered in + expect( + getPixelSum(img, Math.ceil(img.width / 3), img.width, 0, img.height) > 0 + ).toBe(true); + + await scatterplot.draw([...points], { preventFilterReset: true }); + + // `isPointsFiltered` should be `true` as draw has been invoked with preventFilterReset + expect(scatterplot.get('isPointsFiltered')).toBe(true); + + // the filtered points should be the same as before + expect( + isSameElements(scatterplot.get('filteredPoints'), filteredPoints) + ).toBe(true); + + img = scatterplot.export(); + + // The left side of the image should be empty as points #3 and #4 are filtered out + expect( + getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height) + ).toBe(0); + + // The right side of the image should *not* be empty as points #0 to #2 are filtered in + expect( + getPixelSum(img, Math.ceil(img.width / 3), img.width, 0, img.height) > 0 + ).toBe(true); + + await scatterplot.draw([...points, [0.5, 0.5]], { + preventFilterReset: true, + }); + + // `isPointsFiltered` should be `false` as draw has been invoked with different number of points + expect(scatterplot.get('isPointsFiltered')).toBe(false); + + // the filtered points should be reset as draw has been invoked with different number of points + expect(scatterplot.get('filteredPoints').length).toBe(points.length + 1); + + img = scatterplot.export(); + + // The left side of the image should *not* be empty as the filter was reset + expect( + getPixelSum(img, 0, Math.ceil(img.width / 3), 0, img.height) > 0 + ).toBe(true); + + scatterplot.destroy(); +}); + +test('select()', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + + await scatterplot.draw(points); + + let selectedPoints = []; + const selectHandler = ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }; + const deselectHandler = () => { + selectedPoints = []; + }; + scatterplot.subscribe('select', selectHandler); + scatterplot.subscribe('deselect', deselectHandler); + + scatterplot.select([0, 2, 4]); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 4]); + expect(scatterplot.get('selectedPoints')).toEqual([0, 2, 4]); + + scatterplot.deselect(); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + scatterplot.select([0, 2, 4]); + + await wait(0); + + expect(scatterplot.get('selectedPoints')).toEqual([0, 2, 4]); + + scatterplot.select([]); + + await wait(0); + + expect(selectedPoints.length).toBe(0); + + scatterplot.select([0, 2, 4], { preventEvent: true }); + + await wait(0); + + expect(selectedPoints.length + scatterplot.get('selectedPoints').length).toBe(3); + + scatterplot.deselect(); + scatterplot.select([0, 2, 4]); + scatterplot.deselect({ preventEvent: true }); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 4]); + + scatterplot.select(2); + + await wait(0); + + expect(selectedPoints).toEqual([2]); + + scatterplot.select(-1); + + await wait(0); + + expect(selectedPoints).toEqual([]); + + scatterplot.select([0, -1, 2, 4, 6]); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 4]); + + scatterplot.destroy(); +}); + +test('hover() with columnar data', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const data = { + x: [0, 1, 1, -1, -1], + y: [0, 1, -1, -1, 1], + }; + + await scatterplot.draw(data); + + let hovering; + const pointoverHandler = (newHovering) => { + hovering = newHovering; + }; + const pointoutHandler = () => { + hovering = undefined; + }; + scatterplot.subscribe('pointover', pointoverHandler); + scatterplot.subscribe('pointout', pointoutHandler); + + scatterplot.hover(0); + + await wait(0); + + expect(hovering).toBe(0); + + scatterplot.hover(); + + await wait(0); + + expect(hovering).toBe(undefined); + + scatterplot.hover(2, { preventEvent: true }); + + await wait(0); + + // should be silently hovering point 2 + expect(hovering).toBe(undefined); + + scatterplot.hover(4); + scatterplot.hover(undefined, { preventEvent: true }); + + await wait(0); + + // should have silently stopped hovering point 4 + expect(hovering).toBe(4); + + scatterplot.hover(-1); + + await wait(0); + + // Point 4 should still be registered as being hovered because -1 is invalid + expect(hovering).toBe(4); + + scatterplot.hover(6); + + await wait(0); + + // Point 4 should still be registered as being hovered because 6 is invalid + expect(hovering).toBe(4); + + scatterplot.destroy(); +}); + +test('filter()', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + + await scatterplot.draw(points); + + expect(scatterplot.get('isPointsFiltered')).toBe(false); + + expect( + isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) + ).toBe(true); + + let filteredPoints = []; + const filterHandler = ({ points: newFilteredPoints }) => { + filteredPoints = [...newFilteredPoints]; + }; + const unfilterHandler = () => { + filteredPoints = scatterplot.get('filteredPoints'); + }; + scatterplot.subscribe('filter', filterHandler); + scatterplot.subscribe('unfilter', unfilterHandler); + + let hoveredPoint; + const pointOverHandler = (pointIdx) => { + hoveredPoint = pointIdx; + }; + const pointOutHandler = () => { + hoveredPoint = undefined; + }; + scatterplot.subscribe('pointover', pointOverHandler); + scatterplot.subscribe('pointout', pointOutHandler); + + await scatterplot.filter([1, 3]); + await wait(0); + + expect( + isSameElements(filteredPoints, [1, 3]) + ).toBe(true); + + expect(scatterplot.get('isPointsFiltered')).toBe(true); + + expect( + isSameElements(scatterplot.get('pointsInView'), [1, 3]) + ).toBe(true); + + expect( + isSameElements(scatterplot.get('filteredPoints'), [1, 3]) + ).toBe(true); + + scatterplot.hover(1); + await wait(0); + + expect(hoveredPoint).toBe(1); + + scatterplot.hover(2); + await wait(0); + + // should not be able to hover a point that is filtered out + expect(hoveredPoint).toBe(undefined); + + let selectedPoints = []; + const selectHandler = ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }; + const deselectHandler = () => { + selectedPoints = []; + }; + scatterplot.subscribe('select', selectHandler); + scatterplot.subscribe('deselect', deselectHandler); + + scatterplot.select([0, 2, 4]); + + await wait(0); + + // should not have selected points 0, 2, and 4 because they are filtered out + expect(selectedPoints.length).toBe(0); + + await scatterplot.filter([]); + + await wait(0); + + expect(filteredPoints.length).toBe(0); + expect(scatterplot.get('filteredPoints').length).toBe(0); + + scatterplot.select([0, 1, 2, 3, 4]); + + await wait(0); + + // should not be able to selected any points because all are filtered out + expect(selectedPoints.length).toBe(0); + + await scatterplot.unfilter(); + await wait(0); + + // should have unfiltered the points + expect( + isSameElements(filteredPoints, [0, 1, 2, 3, 4]) + ).toBe(true); + + expect( + isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) + ).toBe(true); + + expect( + scatterplot.get('isPointsFiltered'), + ).toBe(false); + + scatterplot.select([0, 2, 4]); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 4]); + + await scatterplot.filter([1, 3], { preventEvent: true }); + await wait(0); + + // should have silently filtered down to two points. I.e., the filter + // argument should not have been altered + expect(filteredPoints.length).toBe(5); + expect(scatterplot.get('pointsInView').length).toBe(2); + + await scatterplot.filter([0, 1, 3]); + await scatterplot.unfilter({ preventEvent: true }); + await wait(0); + + // should have silently unfiltered points. I.e., the unfilter argument + // should not have been altered + expect(filteredPoints.length).toBe(3); + expect(scatterplot.get('pointsInView').length).toBe(5); + + await scatterplot.filter(2); + await wait(0); + + expect(filteredPoints.length).toBe(1); + expect(filteredPoints[0]).toBe(2); + expect(scatterplot.get('pointsInView').length).toBe(1); + + await scatterplot.filter(-1); + await wait(0); + + // should have not filtered down to any point because -1 is an invalid point index + expect(filteredPoints.length).toBe(0); + + const pointsToBeFiltered = [0, -1, 2, 4, 6]; + + await scatterplot.filter(pointsToBeFiltered); + await wait(0); + + // should have filtered down to valid points (0, 2, and 4) only + expect( + isSameElements(filteredPoints, [0, 2, 4]) + ).toBe(true); + + // We're testing this due to the following bug where we accidentically + // spliced of from the input argument, which we should never do. + // @see https://github.com/flekschas/regl-scatterplot/issues/197 + expect(pointsToBeFiltered.length).toBe(5); + + scatterplot.destroy(); +}); + +test('`filter() point order consistency`', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(50, 50), + width: 50, + height: 50, + pointColor: ['#FF0000', '#00FF00', '#0000FF'], + pointSize: 50, + opacity: 1, + colorBy: "valueA" + }); + + const dpr = window.devicePixelRatio; + const middlePixel = (50 * dpr * 25 * dpr + 25 * dpr) * 4; + + await scatterplot.draw({ + x: [0, 0, 0], + y: [0, 0, 0], + valueA: [0, 1, 2] + }); + + await wait(25); + + let image = scatterplot.export(); + + // the center pixel should be blue + expect( + Array.from(image.data.slice(middlePixel, middlePixel + 4)) + ).toEqual([0, 0, 255, 255]); + + await scatterplot.filter([1, 0]); + + await wait(25); + + image = scatterplot.export(); + + // the center pixel should be green + expect( + Array.from(image.data.slice(middlePixel, middlePixel + 4)) + ).toEqual([0, 255, 0, 255]); + + await scatterplot.unfilter(); + await scatterplot.filter([0, 1]); + + await wait(25); + + image = scatterplot.export(); + + // the center pixel should still be green + expect( + Array.from(image.data.slice(middlePixel, middlePixel + 4)) + ).toEqual([0, 255, 0, 255]); +}); + +test('test hover, select, and filter options of `draw()`', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(200, 200), + width: 200, + height: 200, + }); + + const points = [ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + ]; + + await scatterplot.draw(points, { + hover: 0, + select: [1, 2], + filter: [0, 2, 3], + }); + + await wait(50); + + expect(scatterplot.get('hoveredPoint')).toBe(0); + expect(scatterplot.get('selectedPoints')).toEqual([2]); + expect( + isSameElements(scatterplot.get('filteredPoints'), [0, 2, 3]) + ).toBe(true); + + scatterplot.destroy(); +}); + +test('zooming with transition', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + const camera = scatterplot.get('camera'); + + let numDrawCalls = 0; + let numTransitionStartCalls = 0; + let numTransitionEndCalls = 0; + + scatterplot.subscribe('draw', () => numDrawCalls++); + scatterplot.subscribe('transitionStart', () => numTransitionStartCalls++); + scatterplot.subscribe('transitionEnd', () => numTransitionEndCalls++); + + await scatterplot.draw([ + [-1, 1], + [1, 1], + [0, 0], + [-1, -1], + [1, -1], + ]); + + expect(numDrawCalls).toBe(1); + + const t0 = performance.now(); + const transitionDuration = 50; + + await scatterplot.zoomToPoints([1, 2], { + transition: true, + transitionDuration, + }); + + expect(numTransitionStartCalls).toBe(1); + expect(numTransitionEndCalls).toBe(1); + expect(performance.now() - t0).toBeGreaterThanOrEqual(transitionDuration); + expect(camera.target[0] - 0.5).toBeLessThanOrEqual(1e-8); + expect(camera.target[1] - 0.5).toBeLessThanOrEqual(1e-8); + + await scatterplot.zoomToOrigin({ transition: true, transitionDuration }); + + expect(numTransitionStartCalls).toBe(2); + expect(numTransitionEndCalls).toBe(2); + expect(camera.target[0]).toBeLessThanOrEqual(1e-8); + expect(camera.target[1]).toBeLessThanOrEqual(1e-8); + + await scatterplot.zoomToLocation([-0.5, -0.5], 0.1, { + transition: true, + transitionDuration, + }); + + expect(numTransitionStartCalls).toBe(3); + expect(numTransitionEndCalls).toBe(3); + expect(camera.target[0] + 0.1).toBeLessThanOrEqual(1e-8); + expect(camera.target[1] + 0.1).toBeLessThanOrEqual(1e-8); + expect(camera.distance[0] - 0.1).toBeLessThanOrEqual(1e-8); + + await scatterplot.zoomToArea( + { x: 0, y: -1, width: 1, height: 1 }, + { transition: true, transitionDuration } + ); + + expect(numTransitionStartCalls).toBe(4); + expect(numTransitionEndCalls).toBe(4); + expect(camera.target[0] - 0.5).toBeLessThanOrEqual(1e-8); + expect(camera.target[1] + 0.5).toBeLessThanOrEqual(1e-8); + + scatterplot.destroy(); +}); + +test('zoomToPoints() before point were drawn should fail', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + try { + await scatterplot.zoomToPoints([1, 2]); + assert.fail('zoomToPoints() should have thrown an error'); + } catch (e) { + expect(e.message).toBe(ERROR_POINTS_NOT_DRAWN); + } + scatterplot.destroy(); +}); + +test('zoomToArea() with non-standard aspect ratio', async () => { + const dims = [ + [200, 200], + [200, 100], + [100, 200], + [333, 123], + ]; + const aspectRatios = [1, 2, 0.3, 5 / 3]; + + const xStart = -8 / 6; + const xWidth = 5 + 8 / 6; + const yStart = -1 / 3; + const yWidth = 2 / 3; + + const points = { + x: Array.from({ length: 500 }, (_, i) => xStart + (i / 499) * xWidth), + y: Array.from({ length: 500 }, (_, i) => yStart + (i / 499) * yWidth), + }; + + const rect = { + x: xStart - EPS, + width: xWidth + EPS * 2, + y: yStart - EPS, + height: yWidth + EPS * 2, + }; + + for (const [width, height] of dims) { + for (const aspectRatio of aspectRatios) { + const scatterplot = createScatterplot({ + canvas: createCanvas(width, height), + aspectRatio, + }); + + // eslint-disable-next-line no-await-in-loop + await scatterplot.draw(points); + // eslint-disable-next-line no-await-in-loop + await scatterplot.zoomToArea(rect); + + expect( + scatterplot.get('pointsInView').length + ).toBe(500); + expect(Math.abs(scatterplot.getScreenPosition(0)[0])).toBeLessThan(0.01); + expect( + Math.abs(scatterplot.getScreenPosition(499)[0] - width) + ).toBeLessThan(0.01); + + scatterplot.destroy(); + } + } +}); + +test('getScreenPosition()', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(100, 100), + width: 100, + height: 100, + }); + + try { + scatterplot.getScreenPosition(0); + assert.fail('getScreenPosition() should have thrown an error'); + } catch (e) { + expect(e.message).toBe(ERROR_POINTS_NOT_DRAWN); + } + + await scatterplot.draw([ + [-1, -1], + [0, 0], + [1, 1], + ]); + + expect(scatterplot.getScreenPosition(0)).toEqual([0, 100]); + expect(scatterplot.getScreenPosition(1)).toEqual([50, 50]); + expect(scatterplot.getScreenPosition(2)).toEqual([100, 0]); + + // A forth point does not exist as we only drew three + expect(scatterplot.getScreenPosition(3)).toBeUndefined(); + + await scatterplot.zoomToPoints([0]); + expect(scatterplot.getScreenPosition(0)).toEqual([50, 50]); + + scatterplot.destroy(); +}); + +test('isSupported', () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + const renderer = scatterplot.get('renderer'); + + expect( + Object.prototype.hasOwnProperty.call(scatterplot, 'isSupported') + ).toBe(true); + + expect( + Object.prototype.hasOwnProperty.call(renderer, 'isSupported') + ).toBe(true); + + expect( + scatterplot.isSupported === true || scatterplot.isSupported === false + ).toBe(true); + + expect( + renderer.isSupported === true || renderer.isSupported === false + ).toBe(true); + + scatterplot.destroy(); +}); + +test('test "drawing" event', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + let numDrawCalls = 0; + let numDrawingCalls = 0; + + scatterplot.subscribe('draw', () => ++numDrawCalls); + scatterplot.subscribe('drawing', () => ++numDrawingCalls); + + await new Promise((resolve) => { + scatterplot.subscribe('init', resolve); + }); + + const isDrawn = scatterplot.draw([[-1, 1]]); + await nextAnimationFrame(); + + expect(numDrawCalls).toBe(0); + expect(numDrawingCalls).toBe(1); + + await isDrawn; + + expect(numDrawCalls).toBe(1); + + scatterplot.destroy(); +}); + +test('spatial index', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const x = [-1, 1, 0, -1, 1]; + const y = [1, 1, 0, -1, -1]; + const z1 = [0.2, 0.4, 0.6, 0.8, 1]; + const z2 = [1, 0.8, 0.6, 0.4, 0.2]; + + await scatterplot.draw({ x, y, z: z1 }); + + await scatterplot.zoomToArea( + { + x: -EPS, + y: -EPS, + width: 1 + 2 * EPS, + height: 1 + 2 * EPS, + }, + { log: true } + ); + + expect(scatterplot.get('pointsInView')).toEqual([1, 2]); + + const spatialIndex = scatterplot.get('spatialIndex'); + + // Because `x` and `y` remain unchanged, we can reuse the spatial index + // from the previous draw call. + await scatterplot.draw({ x, y, z: z2 }, { spatialIndex }); + + await scatterplot.zoomToArea({ + x: -1 - EPS, + y: -1 - EPS, + width: 1 + 2 * EPS, + height: 1 + 2 * EPS, + }); + + // The reused spatial index should work as before + expect(scatterplot.get('pointsInView')).toEqual([2, 3]); + + scatterplot.destroy(); +}); + +test('drawAnnotations()', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(100, 100), + width: 100, + height: 100, + }); + + await scatterplot.drawAnnotations([ + { x: 0.9, lineColor: [1, 1, 1, 0.1], lineWidth: 1 }, + { y: 0.9, lineColor: [1, 1, 1, 0.1], lineWidth: 1 }, + { + x1: -0.8, + y1: -0.8, + x2: -0.6, + y2: -0.6, + lineColor: [1, 1, 1, 0.25], + lineWidth: 1, + }, + { + x: -0.8, + y: 0.6, + width: 0.2, + height: 0.2, + lineColor: [1, 1, 1, 0.25], + lineWidth: 1, + }, + { + vertices: [ + [0.6, 0.8], + [0.8, 0.8], + [0.8, 0.6], + [0.6, 0.6], + [0.6, 0.8], + ], + lineColor: '#D55E00', + lineWidth: 2, + }, + ]); + + let img = scatterplot.export(); + let w = img.width; + let h = img.height; + const wp = w * 0.1; + const hp = w * 0.1; + + expect(getPixelSum(img, 0, w, 0, hp)).toBeGreaterThan(0); + expect(getPixelSum(img, w - wp, w, 0, h)).toBeGreaterThan(0); + expect(getPixelSum(img, wp, 2 * wp, h - hp * 2, h - hp)).toBeGreaterThan(0); + expect(getPixelSum(img, wp, 2 * wp, hp, 2 * hp)).toBeGreaterThan(0); + expect(getPixelSum(img, wp - 2 * wp, w - wp, hp, 2 * hp)).toBeGreaterThan(0); + + await scatterplot.clearAnnotations(); + + img = scatterplot.export(); + w = img.width; + h = img.height; + expect(getPixelSum(img, 0, w, 0, h)).toBe(0); + + scatterplot.destroy(); +}); + +test('pointScaleMode', async () => { + const dim = 100; + const scatterplot = createScatterplot({ + canvas: createCanvas(dim, dim), + width: dim, + height: dim, + pointSize: 10, + }); + + expect(scatterplot.get('pointScaleMode')).toBe(DEFAULT_POINT_SCALE_MODE); + + await scatterplot.draw([[0, 0]]); + + const initialImage = scatterplot.export(); + const initialPixelSum = getPixelSum(initialImage, 0, dim, 0, dim); + + expect(initialPixelSum).toBeGreaterThan(0); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const asinhImage = scatterplot.export(); + const asinhPixelSum = getPixelSum(asinhImage, 0, dim, 0, dim); + + expect(asinhPixelSum).toBeGreaterThan(initialPixelSum); + + // Zoom back to the origin + await scatterplot.zoomToLocation([0, 0], 1); + + scatterplot.set({ pointScaleMode: 'constant' }); + expect(scatterplot.get('pointScaleMode')).toBe('constant'); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const constantImage = scatterplot.export(); + const constantPixelSum = getPixelSum(constantImage, 0, dim, 0, dim); + + expect(constantPixelSum).toBe(initialPixelSum); + + // Zoom back to the origin + await scatterplot.zoomToLocation([0, 0], 1); + + scatterplot.set({ pointScaleMode: 'linear' }); + expect(scatterplot.get('pointScaleMode')).toBe('linear'); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const linearImage = scatterplot.export(); + const linearPixelSum = getPixelSum(linearImage, 0, dim, 0, dim); + + expect(linearPixelSum).toBeGreaterThan(asinhPixelSum); + + scatterplot.set({ pointScaleMode: 'nonsense' }); + expect(scatterplot.get('pointScaleMode')).toBe('asinh'); + + scatterplot.destroy(); +}); diff --git a/tests/spatial-index.js b/tests/spatial-index.js deleted file mode 100644 index b4619af..0000000 --- a/tests/spatial-index.js +++ /dev/null @@ -1,83 +0,0 @@ -import '@babel/polyfill'; -import { test } from 'zora'; - -import createScatterplot, { createSpatialIndex } from '../src'; - -import { createCanvas, catchError } from './utils'; - -test( - 'spatial index', - catchError(async (t) => { - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - const x = [-1, 1, 0, -1, 1]; - const y = [1, 1, 0, -1, -1]; - const z1 = [0.2, 0.4, 0.6, 0.8, 1]; - const z2 = [1, 0.8, 0.6, 0.4, 0.2]; - - await scatterplot.draw({ x, y, z: z1 }); - - await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); - - t.equal( - scatterplot.get('pointsInView'), - [1, 2], - 'should have points #1 and #2 in the view' - ); - - const spatialIndex = scatterplot.get('spatialIndex'); - - // Because `x` and `y` remain unchanged, we can reuse the spatial index - // from the previous draw call. - await scatterplot.draw({ x, y, z: z2 }, { spatialIndex }); - - await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); - - // The reused spatial index should work as before - t.equal( - scatterplot.get('pointsInView'), - [2, 3], - 'should have points #2 and #3 in the view' - ); - - scatterplot.destroy(); - }) -); - -test( - 'create spatial index', - catchError(async (t) => { - const points = { - x: [-1, 1, 0, -1, 1], - y: [1, 1, 0, -1, -1], - z: [0.2, 0.4, 0.6, 0.8, 1], - }; - - const spatialIndex1 = await createSpatialIndex(points); - const spatialIndex2 = await createSpatialIndex(points, { useWorker: true }); - - const scatterplot = createScatterplot({ canvas: createCanvas() }); - - await scatterplot.draw(points, { spatialIndex: spatialIndex1 }); - - await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); - - t.equal( - scatterplot.get('pointsInView'), - [1, 2], - 'should have points #1 and #2 in the view' - ); - - await scatterplot.draw(points, { spatialIndex: spatialIndex2 }); - - await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); - - t.equal( - scatterplot.get('pointsInView'), - [2, 3], - 'should have points #2 and #3 in the view' - ); - - scatterplot.destroy(); - }) -); diff --git a/tests/spatial-index.test.js b/tests/spatial-index.test.js new file mode 100644 index 0000000..3296ead --- /dev/null +++ b/tests/spatial-index.test.js @@ -0,0 +1,62 @@ +import '@babel/polyfill'; + +import { expect, test } from 'vitest'; + +import createScatterplot, { createSpatialIndex } from '../src'; + +import { createCanvas } from './utils'; + +test('spatial index', async () => { + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + const x = [-1, 1, 0, -1, 1]; + const y = [1, 1, 0, -1, -1]; + const z1 = [0.2, 0.4, 0.6, 0.8, 1]; + const z2 = [1, 0.8, 0.6, 0.4, 0.2]; + + await scatterplot.draw({ x, y, z: z1 }); + + await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); + + expect(scatterplot.get('pointsInView')).toEqual([1, 2]); + + const spatialIndex = scatterplot.get('spatialIndex'); + + // Because `x` and `y` remain unchanged, we can reuse the spatial index + // from the previous draw call. + await scatterplot.draw({ x, y, z: z2 }, { spatialIndex }); + + await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); + + // The reused spatial index should work as before + expect(scatterplot.get('pointsInView')).toEqual([2, 3]); + + scatterplot.destroy(); +}); + +test('create spatial index', async () => { + const points = { + x: [-1, 1, 0, -1, 1], + y: [1, 1, 0, -1, -1], + z: [0.2, 0.4, 0.6, 0.8, 1], + }; + + const spatialIndex1 = await createSpatialIndex(points); + const spatialIndex2 = await createSpatialIndex(points, { useWorker: true }); + + const scatterplot = createScatterplot({ canvas: createCanvas() }); + + await scatterplot.draw(points, { spatialIndex: spatialIndex1 }); + + await scatterplot.zoomToArea({ x: 0, y: 0, width: 1, height: 1 }); + + expect(scatterplot.get('pointsInView')).toEqual([1, 2]); + + await scatterplot.draw(points, { spatialIndex: spatialIndex2 }); + + await scatterplot.zoomToArea({ x: -1, y: -1, width: 1, height: 1 }); + + expect(scatterplot.get('pointsInView')).toEqual([2, 3]); + + scatterplot.destroy(); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js new file mode 100644 index 0000000..1960aec --- /dev/null +++ b/tests/utils.test.js @@ -0,0 +1,53 @@ +/* eslint no-console: 1, no-undef: 1, no-unused-vars: 1 */ + +import '@babel/polyfill'; +import { expect, test } from 'vitest'; + +import { checkSupport } from '../src'; + +import { toRgba, isNormFloatArray, isValidBBox } from '../src/utils'; + +const EPS = 1e-7; + +test('isValidBBox()', () => { + expect(isValidBBox([1, 2, 3, 4])).toBe(true); + // x range is zero + expect(isValidBBox([1, 2, 1, 4])).toBe(false); + // y range is zero + expect(isValidBBox([1, 2, 3, 2])).toBe(false); + // infinity + expect(isValidBBox([1, Infinity, 3, 2])).toBe(false); + // -infinity + expect(isValidBBox([1, -Infinity, 3, 2])).toBe(false); + // NaN + expect(isValidBBox([1, Number.NaN, 3, 2])).toBe(false); +}); + +test('isNormFloatArray()', () => { + expect(isNormFloatArray([255, 0, 0, 255])).toBe(false); + expect(isNormFloatArray([1, 2, 3, 4])).toBe(false); + expect(isNormFloatArray([0, 0, 0, 0.5])).toBe(true); + expect(isNormFloatArray([0.5, 1, 0.005, 1])).toBe(true); + + // Inconclusive + // [0, 0, 0, 1] could be [0, 0, 0, 1] or [0, 0, 0, 1/255] + expect(isNormFloatArray([0, 0, 0, 1])).toBe(true); + // [1, 1, 1, 1] could be [1, 1, 1, 1] or [1/255, 1/255, 1/255, 1/255] + expect(isNormFloatArray([1, 1, 1, 1])).toBe(true); +}); + +test('toRgba()', () => { + expect(toRgba('#ff0000')).toEqual([255, 0, 0, 255]); + expect(toRgba('#ff0000', true)).toEqual([1, 0, 0, 1]); + expect(toRgba([255, 0, 0])).toEqual([255, 0, 0, 255]); + expect(toRgba([1, 0, 0], true)).toEqual([1, 0, 0, 1]); + expect(toRgba([255, 0, 0, 153])).toEqual([255, 0, 0, 153]); + expect(toRgba([153, 0, 0, 153], true)).toEqual([0.6, 0, 0, 0.6]); + expect(toRgba([1, 0, 0, 0.6])).toEqual([255, 0, 0, 153]); + expect(toRgba([0, 0, 0, 0.1], true)).toEqual([0, 0, 0, 0.1]); + expect(toRgba([1, 0, 0, 0.6])).toEqual([255, 0, 0, 153]); +}); + +test('checkSupport()', () => { + expect(checkSupport() === true || checkSupport() === false).toBe(true); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..f7611e2 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + provider: 'playwright', + enabled: true, + name: 'chromium', + headless: true, + screenshot: 'off', + }, + coverage: { + include: ['src'], + reporter: ['text', 'json-summary', 'json'], + }, + }, +})