diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23b4528ae..0c6d1ad62 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -101,7 +101,7 @@ jobs: # - name: Setup tmate session # uses: mxschmitt/action-tmate@v3 - name: Install Python packaging/test tools - run: pip install --upgrade pip tox nox wheel numpy -r python/requirements-test.txt + run: pip install tox nox wheel numpy -r python/requirements-test.txt - uses: ./.github/actions/setup-firefox - run: nox -s lint format mypy - name: Check for dirty working directory @@ -296,5 +296,6 @@ jobs: - run: ./src/mesh/draco/build.sh - run: ./src/sliceview/compresso/build.sh - run: ./src/sliceview/png/build.sh + - run: ./src/sliceview/jxl/build.sh # Check that there are no differences. - run: git diff --exit-code diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml index 4491f8f97..2fe7f186b 100644 --- a/.github/workflows/deploy_preview.yml +++ b/.github/workflows/deploy_preview.yml @@ -1,67 +1,51 @@ -name: Deploy preview +name: Build and Deploy Staging on: - workflow_run: - workflows: ["Build preview"] - types: [completed] + push: + branches: + - staging + - "**" + pull_request: + branches: + - staging + - "**" jobs: deploy: runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - name: "Create commit status" - uses: actions/github-script@v7 + - name: Checkout Repository + uses: actions/checkout@v4 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const commitId = "${{ github.event.workflow_run.head_commit.id }}"; - await github.rest.repos.createCommitStatus({ - context: "client-preview", - owner: context.repo.owner, - repo: context.repo.repo, - sha: commitId, - state: "pending", - description: `Creating preview`, - target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - }); - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: client - path: dist/client - github-token: "${{ secrets.GITHUB_TOKEN }}" - run-id: "${{ github.event.workflow_run.id }}" - - name: Get PR ID - # https://github.com/orgs/community/discussions/25220#discussioncomment-7532132 - id: pr-id - run: | - PR_ID=$(gh run view -R ${{ github.repository }} ${{ github.event.workflow_run.id }} | grep -oP '#[0-9]+ . ${{ github.event.workflow_run.id }}' | grep -oP '#[0-9]+' | cut -c 2-) - echo "pr-id=${PR_ID}" >> $GITHUB_OUTPUT - env: - GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - - uses: FirebaseExtended/action-hosting-deploy@v0 - id: deploy + ref: ${{ github.event.inputs.commit_sha || github.sha }} + + - name: Set short sha name for sub-directory + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + - name: Check outputs + run: echo ${{ steps.vars.outputs.sha_short }} + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - repoToken: "${{ secrets.GITHUB_TOKEN }}" - firebaseServiceAccount: "${{ secrets.FIREBASE_HOSTING_SERVICE_ACCOUNT_KEY }}" - expires: 30d - channelId: "pr${{ steps.pr-id.outputs.pr-id }}" - projectId: neuroglancer-demo - target: app - - name: "Update commit status" - uses: actions/github-script@v7 + node-version: 20.x + + - name: Install Dependencies + run: npm install + + - name: Build Project + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const expires = new Date("${{ steps.deploy.outputs.expire_time }}"); - const commitId = "${{ github.event.workflow_run.head_commit.id }}"; - await github.rest.repos.createCommitStatus({ - context: "client-preview", - owner: context.repo.owner, - repo: context.repo.repo, - sha: commitId, - state: "success", - target_url: "${{ steps.deploy.outputs.details_url }}", - description: `Preview created, expires at: ${expires.toISOString()}`, - }); + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Upload to S3 + run: | + aws s3 sync dist/client/ ${{ secrets.CLOUDFRONT_DEPLOYMENT_LOCATION }}/staging/${{ steps.vars.outputs.sha_short }}/ --delete + + - name: Display URL for Neuroglancer + run: echo "https://neuroglancer.lincbrain.org/cloudfront/frontend/staging/${{ steps.vars.outputs.sha_short }}/index.html" diff --git a/.gitignore b/.gitignore index b07078cb6..5bcb7a3af 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tsconfig.tsbuildinfo .eslintcache /lib /.firebase +.idea/ +yarn.lock +.DS_Store diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..16bb43bef --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,25 @@ +# to run locally: +# docker build -f Dockerfile.dev -t . +# docker run -v $(pwd):/app -p 8080:8080 --rm + +FROM node:20.11.1 + +WORKDIR /app + +# Copy package.json and package-lock.json for installing dependencies +COPY package*.json ./ + +# Install project dependencies +RUN npm install + +# Copy the rest of your app's source code from your host to your image filesystem. +COPY . . + +# Install project dependencies +RUN npm i + +# Vue CLI serves on port 8080 by default, expose that port +EXPOSE 8080 + +# Command to run the app using npm +CMD ["npm", "run", "dev-server"] diff --git a/README.md b/README.md index 67ce4388f..58e47b2e0 100644 --- a/README.md +++ b/README.md @@ -187,3 +187,16 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +# Local Docker development + +To control versioning and development on your local machine, please reference the `Dockerfile.dev` as a convenience. + +To run the Dockerfile: + +```shell +docker build -f Dockerfile.dev -t . +docker run -v $(pwd):/app -p 8080:8080 --rm +``` + +Hot-reloading should be present. diff --git a/package-lock.json b/package-lock.json index 88e158458..86d4707d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,27 +9,19 @@ "version": "2.40.1", "license": "Apache-2.0", "dependencies": { - "axios": "^1.7.7", - "browserify-fs": "^1.0.0", - "buffer": "^6.0.3", "codemirror": "^5.61.1", - "dotenv": "^16.4.5", "gl-matrix": "3.1.0", "glsl-editor": "^1.0.0", "ikonate": "github:mikolajdobrucki/ikonate#a86b4107c6ec717e7877f880a930d1ccf0b59d89", "lodash-es": "^4.17.21", - "mathjs": "^13.2.0", "nifti-reader-js": "^0.6.8", - "numcodecs": "^0.3.1", - "pako": "^2.1.0", - "path-browserify": "^1.0.1" + "numcodecs": "^0.3.2", + "pako": "^2.1.0" }, "devDependencies": { "@types/codemirror": "5.60.15", - "@types/dotenv": "^6.1.1", "@types/gl-matrix": "^2.4.5", "@types/lodash-es": "^4.17.12", - "@types/mathjs": "^9.4.1", "@types/node": "^20.14.12", "@types/pako": "^2.0.3", "@types/yargs": "^17.0.32", @@ -40,6 +32,7 @@ "css-loader": "^7.1.2", "esbuild": "^0.23.0", "esbuild-loader": "^4.2.2", + "eslint": "^8.56.0", "eslint-formatter-codeframe": "^7.32.1", "eslint-import-resolver-typescript": "^3.6.1", "eslint-interactive": "^10.8.0", @@ -71,7 +64,6 @@ "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -193,10 +185,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", - "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", - "license": "MIT", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", + "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -663,7 +655,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -687,7 +678,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -698,7 +688,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -711,7 +700,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -721,7 +709,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", @@ -736,7 +723,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -747,7 +733,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -760,7 +745,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "peer": true, "engines": { "node": ">=12.22" }, @@ -773,8 +757,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@inquirer/confirm": { "version": "3.1.17", @@ -1516,16 +1499,6 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, - "node_modules/@types/dotenv": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", - "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/eslint": { "version": "8.56.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", @@ -1660,16 +1633,6 @@ "@types/lodash": "*" } }, - "node_modules/@types/mathjs": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@types/mathjs/-/mathjs-9.4.1.tgz", - "integrity": "sha512-pEvgJ9c0LkVSZODbBuxeFngyhg/xMpZElcmvtFLayUXEgt6I4fGcMaxlPspV4kIMucmY6W4YwmtaWyTAQpqsrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mathjs": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2028,8 +1991,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@vitest/browser": { "version": "2.0.4", @@ -2589,23 +2551,6 @@ "node": ">=6.5" } }, - "node_modules/abstract-leveldown": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-0.12.4.tgz", - "integrity": "sha512-TOod9d5RDExo6STLMGa+04HGkl+TlMfbDnTyN93/ETJ9DpQ0DaYLqcMZlbXvdc4W3vVo1Qrl+WhSp8zvDsJ+jA==", - "license": "MIT", - "dependencies": { - "xtend": "~3.0.0" - } - }, - "node_modules/abstract-leveldown/node_modules/xtend": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", - "integrity": "sha512-sp/sT9OALMjRW1fKDlPeuSZlDQpkqReA0pyJukniWbTGoEKefHxhGJynE3PNhUMlcM8qWIjPwecwCw4LArS5Eg==", - "engines": { - "node": ">=0.4" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2665,7 +2610,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3042,8 +2986,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/aria-query": { "version": "5.3.0", @@ -3281,17 +3224,6 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" }, - "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/b4a": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", @@ -3353,6 +3285,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -3595,16 +3528,6 @@ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, - "node_modules/browserify-fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browserify-fs/-/browserify-fs-1.0.0.tgz", - "integrity": "sha512-8LqHRPuAEKvyTX34R6tsw4bO2ro6j9DmlYBhiYWHRM26Zv2cBw1fJOU0NeUQ0RkXkPn/PFBjhA0dm4AgaBurTg==", - "dependencies": { - "level-filesystem": "^1.0.1", - "level-js": "^2.1.3", - "levelup": "^0.18.2" - } - }, "node_modules/browserslist": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", @@ -3641,6 +3564,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, "funding": [ { "type": "github", @@ -3655,7 +3579,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -4049,15 +3972,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/clone/-/clone-0.1.19.tgz", - "integrity": "sha512-IO78I0y6JcSpEPHzK4obKdsL7E7oLdRVDVOLwr2Hkbjsb+Eoz0dxW6tef0WizoKu0gLC4oZSZuEF4U2K6w1WQw==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -4140,19 +4054,6 @@ "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true }, - "node_modules/complex.js": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.2.5.tgz", - "integrity": "sha512-U3pSYTZz5Af/xvHgKQkJYHBMGmae7Ms51qqJougCR05YWF1Fihef4LRfOpBFONH2gvPFHMZq2rhx0I44DG23xw==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -4688,12 +4589,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "license": "MIT" - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4894,15 +4789,6 @@ "node": ">=10" } }, - "node_modules/deferred-leveldown": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-0.2.0.tgz", - "integrity": "sha512-+WCbb4+ez/SZ77Sdy1iadagFiVzMB89IKOBhglgnUkVxOxRWmmFsz8UDSNWh4Rhq+3wr/vMFlYj+rdEwWUDdng==", - "license": "MIT", - "dependencies": { - "abstract-leveldown": "~0.12.1" - } - }, "node_modules/define-data-property": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", @@ -5037,7 +4923,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -5134,18 +5019,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -5367,18 +5240,6 @@ "node": ">=4" } }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "license": "MIT", - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5965,12 +5826,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, - "node_modules/escape-latex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", - "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6008,7 +5863,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6300,7 +6154,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6436,7 +6289,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6447,7 +6299,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6460,7 +6311,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -6490,7 +6340,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6919,7 +6768,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -6993,7 +6841,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -7019,7 +6866,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -7039,6 +6885,7 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true, "funding": [ { "type": "individual", @@ -7063,12 +6910,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/foreach": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", - "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==", - "license": "MIT" - }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -7320,20 +7161,6 @@ "node": ">= 10.0.0" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -7364,19 +7191,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -7470,38 +7284,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fwd-stream": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fwd-stream/-/fwd-stream-1.0.4.tgz", - "integrity": "sha512-q2qaK2B38W07wfPSQDKMiKOD5Nzv2XyuvQlrmh1q0pxyHNanKHq8lwQ6n9zHucAwA5EbzRJKEgds2orn88rYTg==", - "dependencies": { - "readable-stream": "~1.0.26-4" - } - }, - "node_modules/fwd-stream/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, - "node_modules/fwd-stream/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/fwd-stream/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "license": "MIT" - }, "node_modules/geckodriver": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-4.4.2.tgz", @@ -7699,7 +7481,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -7748,7 +7529,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -7764,7 +7544,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -8340,16 +8119,11 @@ "postcss": "^8.1.0" } }, - "node_modules/idb-wrapper": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/idb-wrapper/-/idb-wrapper-1.7.2.tgz", - "integrity": "sha512-zfNREywMuf0NzDo9mVsL0yegjsirJxHpKHvWcyRozIqQy89g0a3U+oBPOCN4cc0oCiOuYgZHimzaW/R46G1Mpg==", - "license": "MIT" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -8505,16 +8279,10 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.19" } }, - "node_modules/indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8595,14 +8363,6 @@ "node": ">= 10" } }, - "node_modules/is": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", - "integrity": "sha512-ajQCouIvkcSnl2iRdK70Jug9mohIHVX9uKpoWnl115ov0R5mzBvRrXxrnHbsA+8AdwCwc/sfw7HXmd4I5EJBdQ==", - "engines": { - "node": "*" - } - }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -8858,11 +8618,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-object": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-0.1.2.tgz", - "integrity": "sha512-GkfZZlIZtpkFrqyAXPQSRBMsaHAw+CgoKe2HXAkjd/sfoI9+hS8PT4wg2rJxdQyUKr7N2vHJbg7/jQtE5l5vBQ==" - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -9038,12 +8793,6 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, - "node_modules/isbuffer": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/isbuffer/-/isbuffer-0.0.0.tgz", - "integrity": "sha512-xU+NoHp+YtKQkaM2HsQchYn0sltxMxew0HavMfHbjnucBoTSGbw745tL+Z7QBANleWM1eEQMenEpi174mIeS4g==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9081,12 +8830,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "license": "MIT" - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -9216,7 +8959,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -9401,8 +9143,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/json-stringify-safe": { "version": "5.0.1", @@ -9534,240 +9275,11 @@ "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", "deprecated": "use String.prototype.padStart()" }, - "node_modules/level-blobs": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/level-blobs/-/level-blobs-0.1.7.tgz", - "integrity": "sha512-n0iYYCGozLd36m/Pzm206+brIgXP8mxPZazZ6ZvgKr+8YwOZ8/PPpYC5zMUu2qFygRN8RO6WC/HH3XWMW7RMVg==", - "dependencies": { - "level-peek": "1.0.6", - "once": "^1.3.0", - "readable-stream": "^1.0.26-4" - } - }, - "node_modules/level-blobs/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, - "node_modules/level-blobs/node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/level-blobs/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "license": "MIT" - }, - "node_modules/level-filesystem": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/level-filesystem/-/level-filesystem-1.2.0.tgz", - "integrity": "sha512-PhXDuCNYpngpxp3jwMT9AYBMgOvB6zxj3DeuIywNKmZqFj2djj9XfT2XDVslfqmo0Ip79cAd3SBy3FsfOZPJ1g==", - "dependencies": { - "concat-stream": "^1.4.4", - "errno": "^0.1.1", - "fwd-stream": "^1.0.4", - "level-blobs": "^0.1.7", - "level-peek": "^1.0.6", - "level-sublevel": "^5.2.0", - "octal": "^1.0.0", - "once": "^1.3.0", - "xtend": "^2.2.0" - } - }, - "node_modules/level-filesystem/node_modules/xtend": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", - "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/level-fix-range": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-1.0.2.tgz", - "integrity": "sha512-9llaVn6uqBiSlBP+wKiIEoBa01FwEISFgHSZiyec2S0KpyLUkGR4afW/FCZ/X8y+QJvzS0u4PGOlZDdh1/1avQ==", - "license": "MIT" - }, - "node_modules/level-hooks": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/level-hooks/-/level-hooks-4.5.0.tgz", - "integrity": "sha512-fxLNny/vL/G4PnkLhWsbHnEaRi+A/k8r5EH/M77npZwYL62RHi2fV0S824z3QdpAk6VTgisJwIRywzBHLK4ZVA==", - "dependencies": { - "string-range": "~1.2" - } - }, - "node_modules/level-js": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/level-js/-/level-js-2.2.4.tgz", - "integrity": "sha512-lZtjt4ZwHE00UMC1vAb271p9qzg8vKlnDeXfIesH3zL0KxhHRDjClQLGLWhyR0nK4XARnd4wc/9eD1ffd4PshQ==", - "license": "BSD-2-Clause", - "dependencies": { - "abstract-leveldown": "~0.12.0", - "idb-wrapper": "^1.5.0", - "isbuffer": "~0.0.0", - "ltgt": "^2.1.2", - "typedarray-to-buffer": "~1.0.0", - "xtend": "~2.1.2" - } - }, - "node_modules/level-js/node_modules/object-keys": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", - "integrity": "sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==", - "license": "MIT" - }, - "node_modules/level-js/node_modules/xtend": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", - "integrity": "sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==", - "dependencies": { - "object-keys": "~0.4.0" - }, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/level-peek": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/level-peek/-/level-peek-1.0.6.tgz", - "integrity": "sha512-TKEzH5TxROTjQxWMczt9sizVgnmJ4F3hotBI48xCTYvOKd/4gA/uY0XjKkhJFo6BMic8Tqjf6jFMLWeg3MAbqQ==", - "license": "MIT", - "dependencies": { - "level-fix-range": "~1.0.2" - } - }, - "node_modules/level-sublevel": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/level-sublevel/-/level-sublevel-5.2.3.tgz", - "integrity": "sha512-tO8jrFp+QZYrxx/Gnmjawuh1UBiifpvKNAcm4KCogesWr1Nm2+ckARitf+Oo7xg4OHqMW76eAqQ204BoIlscjA==", - "license": "MIT", - "dependencies": { - "level-fix-range": "2.0", - "level-hooks": ">=4.4.0 <5", - "string-range": "~1.2.1", - "xtend": "~2.0.4" - } - }, - "node_modules/level-sublevel/node_modules/level-fix-range": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-2.0.0.tgz", - "integrity": "sha512-WrLfGWgwWbYPrHsYzJau+5+te89dUbENBg3/lsxOs4p2tYOhCHjbgXxBAj4DFqp3k/XBwitcRXoCh8RoCogASA==", - "license": "MIT", - "dependencies": { - "clone": "~0.1.9" - } - }, - "node_modules/level-sublevel/node_modules/object-keys": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.2.0.tgz", - "integrity": "sha512-XODjdR2pBh/1qrjPcbSeSgEtKbYo7LqYNq64/TPuCf7j9SfDD3i21yatKoIy39yIWNvVM59iutfQQpCv1RfFzA==", - "deprecated": "Please update to the latest object-keys", - "license": "MIT", - "dependencies": { - "foreach": "~2.0.1", - "indexof": "~0.0.1", - "is": "~0.2.6" - } - }, - "node_modules/level-sublevel/node_modules/xtend": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.0.6.tgz", - "integrity": "sha512-fOZg4ECOlrMl+A6Msr7EIFcON1L26mb4NY5rurSkOex/TWhazOrg6eXD/B0XkuiYcYhQDWLXzQxLMVJ7LXwokg==", - "dependencies": { - "is-object": "~0.1.2", - "object-keys": "~0.2.0" - }, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/levelup": { - "version": "0.18.6", - "resolved": "https://registry.npmjs.org/levelup/-/levelup-0.18.6.tgz", - "integrity": "sha512-uB0auyRqIVXx+hrpIUtol4VAPhLRcnxcOsd2i2m6rbFIDarO5dnrupLOStYYpEcu8ZT087Z9HEuYw1wjr6RL6Q==", - "license": "MIT", - "dependencies": { - "bl": "~0.8.1", - "deferred-leveldown": "~0.2.0", - "errno": "~0.1.1", - "prr": "~0.0.0", - "readable-stream": "~1.0.26", - "semver": "~2.3.1", - "xtend": "~3.0.0" - } - }, - "node_modules/levelup/node_modules/bl": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-0.8.2.tgz", - "integrity": "sha512-pfqikmByp+lifZCS0p6j6KreV6kNU6Apzpm2nKOk+94cZb/jvle55+JxWiByUQ0Wo/+XnDXEy5MxxKMb6r0VIw==", - "license": "MIT", - "dependencies": { - "readable-stream": "~1.0.26" - } - }, - "node_modules/levelup/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, - "node_modules/levelup/node_modules/prr": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz", - "integrity": "sha512-LmUECmrW7RVj6mDWKjTXfKug7TFGdiz9P18HMcO4RHL+RW7MCOGNvpj5j47Rnp6ne6r4fZ2VzyUWEpKbg+tsjQ==", - "license": "MIT" - }, - "node_modules/levelup/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/levelup/node_modules/semver": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-2.3.2.tgz", - "integrity": "sha512-abLdIKCosKfpnmhS52NCTjO4RiLspDfsn37prjzGrp9im5DPJOgh82Os92vtwGh6XdQryKI/7SREZnV+aqiXrA==", - "license": "BSD", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/levelup/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "license": "MIT" - }, - "node_modules/levelup/node_modules/xtend": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", - "integrity": "sha512-sp/sT9OALMjRW1fKDlPeuSZlDQpkqReA0pyJukniWbTGoEKefHxhGJynE3PNhUMlcM8qWIjPwecwCw4LArS5Eg==", - "engines": { - "node": ">=0.4" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9852,7 +9364,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -9883,8 +9394,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -9989,12 +9499,6 @@ "node": "20 || >=22" } }, - "node_modules/ltgt": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", - "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", - "license": "MIT" - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -10013,29 +9517,6 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, - "node_modules/mathjs": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.2.0.tgz", - "integrity": "sha512-P5PZoiUX2Tkghkv3tsSqlK0B9My/ErKapv1j6wdxd0MOrYQ30cnGE4LH/kzYB2gA5rN46Njqc4cFgJjaxgijoQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.25.6", - "complex.js": "^2.1.1", - "decimal.js": "^10.4.3", - "escape-latex": "^1.2.0", - "fraction.js": "^4.3.7", - "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", - "tiny-emitter": "^2.1.0", - "typed-function": "^4.2.1" - }, - "bin": { - "mathjs": "bin/cli.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -10563,9 +10044,10 @@ } }, "node_modules/numcodecs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.1.tgz", - "integrity": "sha512-ywIyGpJ+c6Ojktq9a8jsWSy12ZSUcW/W+I3jlH0q0zv9aR/ZiMsN7IrWaNq9YV2FRdLu6r/M6lp35jMA6fug/A==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", "dependencies": { "fflate": "^0.8.0" } @@ -10680,12 +10162,6 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, - "node_modules/octal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/octal/-/octal-1.0.0.tgz", - "integrity": "sha512-nnda7W8d+A3vEIY+UrDQzzboPf1vhs4JYVhff5CDkq9QNoZY7Xrxeo/htox37j9dZf7yNHevZzqtejWgy1vCqQ==", - "license": "MIT" - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10711,6 +10187,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "dependencies": { "wrappy": "1" } @@ -10762,7 +10239,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, - "peer": true, "dependencies": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -10857,7 +10333,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -10873,7 +10348,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -11016,12 +10490,6 @@ "tslib": "^2.0.3" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11341,7 +10809,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11491,13 +10958,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "license": "MIT" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true }, "node_modules/psl": { "version": "1.9.0", @@ -11774,7 +11236,8 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", @@ -12081,7 +11544,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12097,7 +11559,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12108,7 +11569,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12129,7 +11589,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12295,12 +11754,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -13073,12 +12526,6 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, - "node_modules/string-range": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/string-range/-/string-range-1.2.2.tgz", - "integrity": "sha512-tYft6IFi8SjplJpxCUxyqisD3b+R2CSkomrtJYCkvuf1KuCAWgz7YXt4O0jip7efpfCemwHEzTEAO8EuOYgh3w==", - "license": "MIT" - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -13233,7 +12680,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "peer": true, "engines": { "node": ">=8" }, @@ -13514,8 +12960,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/through": { "version": "2.3.8", @@ -13559,12 +13004,6 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", @@ -14139,7 +13578,6 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -14237,26 +13675,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-function": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", - "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, - "node_modules/typedarray-to-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-1.0.4.tgz", - "integrity": "sha512-vjMKrfSoUDN8/Vnqitw2FmstOfuJ73G6CrSEKnf11A6RmasVxHqfeBcnTb6RsL4pTMuV5Zsv9IiHRphMZyckUw==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", @@ -15922,7 +15345,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/ws": { "version": "8.18.0", @@ -16056,7 +15480,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 4547c3d13..b150a5f91 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "git+https://github.com/google/neuroglancer.git" }, "engines": { - "node": ">=20.10 <21 || >=21.2" + "node": ">=20.11 <21 || >=21.2" }, "browserslist": [ "last 2 Chrome versions", @@ -89,9 +89,8 @@ "lodash-es": "^4.17.21", "mathjs": "^13.2.0", "nifti-reader-js": "^0.6.8", - "numcodecs": "^0.3.1", - "pako": "^2.1.0", - "path-browserify": "^1.0.1" + "numcodecs": "^0.3.2", + "pako": "^2.1.0" }, "overrides": { "@puppeteer/browsers": ">=2" diff --git a/python/neuroglancer/json_wrappers.py b/python/neuroglancer/json_wrappers.py index 7c4c0fb96..7c18dc252 100644 --- a/python/neuroglancer/json_wrappers.py +++ b/python/neuroglancer/json_wrappers.py @@ -450,7 +450,7 @@ class _Map(Map): def typed_set(wrapped_type: Callable[[Any], T]): - def wrapper(x, _readonly=False) -> Callable[[Any], Union[set[T], frozenset[T]]]: + def wrapper(x, _readonly=False) -> Union[set[T], frozenset[T]]: set_type = frozenset if _readonly else set kwargs: dict[str, Any] = dict() if hasattr(wrapped_type, "supports_readonly"): diff --git a/src/async_computation/decode_jpeg.ts b/src/async_computation/decode_jpeg.ts index d3f93ac1c..c20dbea21 100644 --- a/src/async_computation/decode_jpeg.ts +++ b/src/async_computation/decode_jpeg.ts @@ -25,6 +25,7 @@ registerAsyncComputation( data: Uint8Array, width: number | undefined, height: number | undefined, + area: number | undefined, numComponents: number | undefined, convertToGrayscale: boolean, ) => { @@ -32,14 +33,14 @@ registerAsyncComputation( parser.parse(data); // Just check that the total number pixels matches the expected value. if ( - width !== undefined && - height !== undefined && - parser.width * parser.height !== width * height + (width !== undefined && width !== parser.width) || + (height !== undefined && height !== parser.height) || + (area !== undefined && parser.width * parser.height !== area) ) { throw new Error( "JPEG data does not have the expected dimensions: " + `width=${parser.width}, height=${parser.height}, ` + - `expected width=${width}, expected height=${height}`, + `expected width=${width}, expected height=${height}, expected area=${area}`, ); } width = parser.width; diff --git a/src/async_computation/decode_jpeg_request.ts b/src/async_computation/decode_jpeg_request.ts index 1df020d75..c5b9a7d1d 100644 --- a/src/async_computation/decode_jpeg_request.ts +++ b/src/async_computation/decode_jpeg_request.ts @@ -16,13 +16,14 @@ import type { DecodedImage } from "#src/async_computation/decode_png_request.js"; import { asyncComputation } from "#src/async_computation/index.js"; -export const decodeJpeg = - asyncComputation< - ( - data: Uint8Array, - width: number | undefined, - height: number | undefined, - numComponents: number | undefined, - convertToGrayscale: boolean, - ) => DecodedImage - >("decodeJpeg"); +export const decodeJpeg = asyncComputation< + ( + data: Uint8Array, + width: number | undefined, + height: number | undefined, + // Expected width * height + area: number | undefined, + numComponents: number | undefined, + convertToGrayscale: boolean, + ) => DecodedImage +>("decodeJpeg"); diff --git a/src/async_computation/decode_jxl.ts b/src/async_computation/decode_jxl.ts new file mode 100644 index 000000000..916e9567f --- /dev/null +++ b/src/async_computation/decode_jxl.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2024 William Silversmith + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { decodeJxl } from "#src/async_computation/decode_jxl_request.js"; +import { registerAsyncComputation } from "#src/async_computation/handler.js"; +import { decompressJxl } from "#src/sliceview/jxl/index.js"; + +registerAsyncComputation( + decodeJxl, + async ( + data: Uint8Array, + area: number | undefined, + numComponents: number | undefined, + bytesPerPixel: number, + ) => { + const result = await decompressJxl( + data, + area, + numComponents, + bytesPerPixel, + ); + return { value: result, transfer: [result.uint8Array.buffer] }; + }, +); diff --git a/src/async_computation/decode_jxl_request.ts b/src/async_computation/decode_jxl_request.ts new file mode 100644 index 000000000..9f1c2fe35 --- /dev/null +++ b/src/async_computation/decode_jxl_request.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 William Silversmith + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { DecodedImage } from "#src/async_computation/decode_png_request.js"; +import { asyncComputation } from "#src/async_computation/index.js"; + +export const decodeJxl = + asyncComputation< + ( + data: Uint8Array, + area: number | undefined, + numComponents: number | undefined, + bytesPerPixel: number, + ) => DecodedImage + >("decodeJxl"); diff --git a/src/async_computation/decode_png.ts b/src/async_computation/decode_png.ts index bc64a2052..ed33df076 100644 --- a/src/async_computation/decode_png.ts +++ b/src/async_computation/decode_png.ts @@ -24,6 +24,8 @@ registerAsyncComputation( data: Uint8Array, width: number | undefined, height: number | undefined, + // Expected width * height + area: number | undefined, numComponents: number | undefined, bytesPerPixel: number, convertToGrayscale: boolean, @@ -32,6 +34,7 @@ registerAsyncComputation( data, width, height, + area, numComponents, bytesPerPixel, convertToGrayscale, diff --git a/src/async_computation/decode_png_request.ts b/src/async_computation/decode_png_request.ts index 06d8001ba..0980b4e45 100644 --- a/src/async_computation/decode_png_request.ts +++ b/src/async_computation/decode_png_request.ts @@ -22,14 +22,15 @@ export interface DecodedImage { uint8Array: Uint8Array; } -export const decodePng = - asyncComputation< - ( - data: Uint8Array, - width: number | undefined, - height: number | undefined, - numComponents: number | undefined, - bytesPerPixel: number, - convertToGrayscale: boolean, - ) => DecodedImage - >("decodePng"); +export const decodePng = asyncComputation< + ( + data: Uint8Array, + width: number | undefined, + height: number | undefined, + // Expected width * height + area: number | undefined, + numComponents: number | undefined, + bytesPerPixel: number, + convertToGrayscale: boolean, + ) => DecodedImage +>("decodePng"); diff --git a/src/datasource/deepzoom/backend.ts b/src/datasource/deepzoom/backend.ts index 8f7cd5e5e..0c7ac55a4 100644 --- a/src/datasource/deepzoom/backend.ts +++ b/src/datasource/deepzoom/backend.ts @@ -111,6 +111,7 @@ export class DeepzoomImageTileSource extends WithParameters( new Uint8Array(responseBuffer), undefined, undefined, + undefined, 3, 1, false, @@ -133,6 +134,7 @@ export class DeepzoomImageTileSource extends WithParameters( new Uint8Array(responseBuffer), undefined, undefined, + undefined, 3, false, ); diff --git a/src/datasource/precomputed/async_computation.ts b/src/datasource/precomputed/async_computation.ts index afd324330..e6b6457a4 100644 --- a/src/datasource/precomputed/async_computation.ts +++ b/src/datasource/precomputed/async_computation.ts @@ -1,4 +1,5 @@ import "#src/async_computation/decode_jpeg.js"; +import "#src/async_computation/decode_jxl.js"; import "#src/async_computation/decode_gzip.js"; import "#src/async_computation/decode_compresso.js"; import "#src/async_computation/decode_png.js"; diff --git a/src/datasource/precomputed/backend.ts b/src/datasource/precomputed/backend.ts index 317890732..ac308f0db 100644 --- a/src/datasource/precomputed/backend.ts +++ b/src/datasource/precomputed/backend.ts @@ -74,6 +74,7 @@ import { decodeCompressedSegmentationChunk } from "#src/sliceview/backend_chunk_ import { decodeCompressoChunk } from "#src/sliceview/backend_chunk_decoders/compresso.js"; import type { ChunkDecoder } from "#src/sliceview/backend_chunk_decoders/index.js"; import { decodeJpegChunk } from "#src/sliceview/backend_chunk_decoders/jpeg.js"; +import { decodeJxlChunk } from "#src/sliceview/backend_chunk_decoders/jxl.js"; import { decodePngChunk } from "#src/sliceview/backend_chunk_decoders/png.js"; import { decodeRawChunk } from "#src/sliceview/backend_chunk_decoders/raw.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; @@ -380,6 +381,7 @@ chunkDecoders.set( ); chunkDecoders.set(VolumeChunkEncoding.COMPRESSO, decodeCompressoChunk); chunkDecoders.set(VolumeChunkEncoding.PNG, decodePngChunk); +chunkDecoders.set(VolumeChunkEncoding.JXL, decodeJxlChunk); @registerSharedObject() export class PrecomputedVolumeChunkSource extends WithParameters( diff --git a/src/datasource/precomputed/base.ts b/src/datasource/precomputed/base.ts index c3414e5e9..ee925af6d 100644 --- a/src/datasource/precomputed/base.ts +++ b/src/datasource/precomputed/base.ts @@ -27,6 +27,7 @@ export enum VolumeChunkEncoding { COMPRESSED_SEGMENTATION = 2, COMPRESSO = 3, PNG = 4, + JXL = 5, } export class VolumeChunkSourceParameters { diff --git a/src/datasource/render/backend.ts b/src/datasource/render/backend.ts index 2a41d2936..ad53c010c 100644 --- a/src/datasource/render/backend.ts +++ b/src/datasource/render/backend.ts @@ -46,8 +46,9 @@ chunkDecoders.set( cancellationToken, [response], new Uint8Array(response), - chunkDataSize[0], - chunkDataSize[1] * chunkDataSize[2], + undefined, + undefined, + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2], 3, true, ); diff --git a/src/display_context.ts b/src/display_context.ts index d25d9e9c2..0438391f5 100644 --- a/src/display_context.ts +++ b/src/display_context.ts @@ -403,6 +403,7 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { rootRect: DOMRect | undefined; resizeGeneration = 0; boundsGeneration = -1; + force3DHistogramForAutoRange = false; private framerateMonitor = new FramerateMonitor(); private continuousCameraMotionInProgress = false; @@ -587,7 +588,7 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { this.updateStarted.dispatch(); const gl = this.gl; const ext = this.framerateMonitor.getTimingExtension(gl); - const query = this.framerateMonitor.startFrameTimeQuery(gl, ext); + this.framerateMonitor.startFrameTimeQuery(gl, ext, this.frameNumber); this.ensureBoundsUpdated(); this.gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); @@ -611,7 +612,7 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { gl.clear(gl.COLOR_BUFFER_BIT); this.gl.colorMask(true, true, true, true); this.updateFinished.dispatch(); - this.framerateMonitor.endFrameTimeQuery(gl, ext, query); + this.framerateMonitor.endLastTimeQuery(gl, ext); this.framerateMonitor.grabAnyFinishedQueryResults(gl); } diff --git a/src/kvstore/special/index.ts b/src/kvstore/special/index.ts index 22423b00e..ac9ee232a 100644 --- a/src/kvstore/special/index.ts +++ b/src/kvstore/special/index.ts @@ -23,7 +23,7 @@ import type { } from "#src/kvstore/index.js"; import { composeByteRangeRequest } from "#src/kvstore/index.js"; import { uncancelableToken } from "#src/util/cancellation.js"; -import { HttpError, isNotFoundError } from "#src/util/http_request.js"; +import { isNotFoundError } from "#src/util/http_request.js"; import type { SpecialProtocolCredentialsProvider } from "#src/util/special_protocol_request.js"; import { cancellableFetchSpecialOk } from "#src/util/special_protocol_request.js"; @@ -85,97 +85,95 @@ class SpecialProtocolKvStore implements ReadableKvStore { const { cancellationToken = uncancelableToken } = options; let { byteRange: byteRangeRequest } = options; const url = this.baseUrl + key; - for (let attempt = 0; ; ++attempt) { - try { - const requestInit: RequestInit = {}; - const rangeHeader = getRangeHeader(byteRangeRequest); - if (rangeHeader !== undefined) { - requestInit.headers = { range: rangeHeader }; - requestInit.cache = byteRangeCacheMode; + + try { + // The HTTP spec supports suffixLength requests directly via "Range: + // bytes=-N" requests, which avoids the need for a separate HEAD request. + // However, per + // https://fetch.spec.whatwg.org/#cors-safelisted-request-header a suffix + // length byte range request header will always trigger an OPTIONS preflight + // request, which would otherwise be avoided. This negates the benefit of + // using a suffixLength request directly. Additionally, some servers such as + // the npm http-server package and https://uk1s3.embassy.ebi.ac.uk/ do not + // correctly handle suffixLength requests or do not correctly handle CORS + // preflight requests. To avoid those issues, always just issue a separate + // HEAD request to determine the length. + let totalSize: number | undefined; + if ( + byteRangeRequest !== undefined && + "suffixLength" in byteRangeRequest + ) { + const totalSize = await this.getObjectLength(url, options); + byteRangeRequest = composeByteRangeRequest( + { offset: 0, length: totalSize }, + byteRangeRequest, + ).outer; + } + const requestInit: RequestInit = {}; + const rangeHeader = getRangeHeader(byteRangeRequest); + if (rangeHeader !== undefined) { + requestInit.headers = { range: rangeHeader }; + requestInit.cache = byteRangeCacheMode; + } + const { response, data } = await cancellableFetchSpecialOk( + this.credentialsProvider, + url, + requestInit, + async (response) => ({ + response, + data: await response.arrayBuffer(), + }), + cancellationToken, + ); + let byteRange: ByteRange | undefined; + if (response.status === 206) { + const contentRange = response.headers.get("content-range"); + if (contentRange === null) { + // Content-range should always be sent, but some buggy servers don't + // send it. + if (byteRangeRequest !== undefined) { + byteRange = { + offset: byteRangeRequest.offset, + length: data.byteLength, + }; + } else { + throw new Error( + "Unexpected HTTP 206 response when no byte range specified.", + ); + } } - const { response, data } = await cancellableFetchSpecialOk( - this.credentialsProvider, - url, - requestInit, - async (response) => ({ - response, - data: await response.arrayBuffer(), - }), - cancellationToken, - ); - let byteRange: ByteRange | undefined; - let totalSize: number | undefined; - if (response.status === 206) { - const contentRange = response.headers.get("content-range"); - if (contentRange === null) { - if (byteRangeRequest !== undefined) { - if ("suffixLength" in byteRangeRequest) { - const objectSize = await this.getObjectLength(url, options); - byteRange = { - offset: objectSize - byteRangeRequest.suffixLength, - length: Number(response.headers.get("content-length")), - }; - } else { - byteRange = { - offset: byteRangeRequest.offset, - length: data.byteLength, - }; - } - } else { - throw new Error( - "Unexpected HTTP 206 response when no byte range specified.", - ); - } + if (contentRange !== null) { + const m = contentRange.match(/bytes ([0-9]+)-([0-9]+)\/([0-9]+|\*)/); + if (m === null) { + throw new Error( + `Invalid content-range header: ${JSON.stringify(contentRange)}`, + ); } - if (contentRange !== null) { - const m = contentRange.match( - /bytes ([0-9]+)-([0-9]+)\/([0-9]+|\*)/, + const beginPos = parseInt(m[1], 10); + const endPos = parseInt(m[2], 10); + if (endPos !== beginPos + data.byteLength - 1) { + throw new Error( + `Length in content-range header ${JSON.stringify( + contentRange, + )} does not match content length ${data.byteLength}`, ); - if (m === null) { - throw new Error( - `Invalid content-range header: ${JSON.stringify(contentRange)}`, - ); - } - const beginPos = parseInt(m[1], 10); - const endPos = parseInt(m[2], 10); - if (endPos !== beginPos + data.byteLength - 1) { - throw new Error( - `Length in content-range header ${JSON.stringify( - contentRange, - )} does not match content length ${data.byteLength}`, - ); - } - totalSize = m[3] === "*" ? undefined : parseInt(m[3], 10); - byteRange = { offset: beginPos, length: data.byteLength }; } + if (m[3] !== "*") { + totalSize = parseInt(m[3], 10); + } + byteRange = { offset: beginPos, length: data.byteLength }; } - if (byteRange === undefined) { - byteRange = { offset: 0, length: data.byteLength }; - totalSize = data.byteLength; - } - return { data: new Uint8Array(data), dataRange: byteRange, totalSize }; - } catch (e) { - if ( - attempt === 0 && - e instanceof HttpError && - e.status === 416 && - options.byteRange !== undefined && - "suffixLength" in options.byteRange - ) { - // Some servers, such as the npm http-server package, do not support suffixLength - // byte-range requests. - const contentLengthNumber = await this.getObjectLength(url, options); - byteRangeRequest = composeByteRangeRequest( - { offset: 0, length: contentLengthNumber }, - byteRangeRequest, - ).outer; - continue; - } - if (isNotFoundError(e)) { - return undefined; - } - throw e; } + if (byteRange === undefined) { + byteRange = { offset: 0, length: data.byteLength }; + totalSize = data.byteLength; + } + return { data: new Uint8Array(data), dataRange: byteRange, totalSize }; + } catch (e) { + if (isNotFoundError(e)) { + return undefined; + } + throw e; } } } diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 1ab88d5e1..2f0bceb0f 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -999,6 +999,7 @@ export class PerspectivePanel extends RenderedDataPanel { sliceViewsPresent: this.sliceViews.size > 0, isContinuousCameraMotionInProgress: this.isContinuousCameraMotionInProgress, + force3DHistogramForAutoRange: this.context.force3DHistogramForAutoRange, }; mat4.copy( diff --git a/src/perspective_view/render_layer.ts b/src/perspective_view/render_layer.ts index c05482230..8935b061f 100644 --- a/src/perspective_view/render_layer.ts +++ b/src/perspective_view/render_layer.ts @@ -66,6 +66,12 @@ export interface PerspectiveViewRenderContext */ isContinuousCameraMotionInProgress: boolean; + /** + * Usually, the histogram in 3D is disabled during camera movement + * This flag is used to force 3D histogram rendering during camera movement + */ + force3DHistogramForAutoRange: boolean; + /** * Specifices how to bind the max projection buffer */ diff --git a/src/sliceview/backend_chunk_decoders/jpeg.ts b/src/sliceview/backend_chunk_decoders/jpeg.ts index 9b814eebe..854b84f66 100644 --- a/src/sliceview/backend_chunk_decoders/jpeg.ts +++ b/src/sliceview/backend_chunk_decoders/jpeg.ts @@ -31,8 +31,9 @@ export async function decodeJpegChunk( cancellationToken, [response], new Uint8Array(response), - chunkDataSize[0], - chunkDataSize[1] * chunkDataSize[2], + undefined, + undefined, + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2], chunkDataSize[3] || 1, false, ); diff --git a/src/sliceview/backend_chunk_decoders/jxl.ts b/src/sliceview/backend_chunk_decoders/jxl.ts new file mode 100644 index 000000000..914367a71 --- /dev/null +++ b/src/sliceview/backend_chunk_decoders/jxl.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2016 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { decodeJxl } from "#src/async_computation/decode_jxl_request.js"; +import { requestAsyncComputation } from "#src/async_computation/request.js"; +import { postProcessRawData } from "#src/sliceview/backend_chunk_decoders/postprocess.js"; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import type { CancellationToken } from "#src/util/cancellation.js"; + +export async function decodeJxlChunk( + chunk: VolumeChunk, + cancellationToken: CancellationToken, + response: ArrayBuffer, +) { + const chunkDataSize = chunk.chunkDataSize!; + const { uint8Array: decoded } = await requestAsyncComputation( + decodeJxl, + cancellationToken, + [response], + new Uint8Array(response), + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2], + chunkDataSize[3] || 1, + 1, // bytesPerPixel + ); + await postProcessRawData(chunk, cancellationToken, decoded); +} diff --git a/src/sliceview/backend_chunk_decoders/png.ts b/src/sliceview/backend_chunk_decoders/png.ts index 82b9cc8fa..f5d34b5e5 100644 --- a/src/sliceview/backend_chunk_decoders/png.ts +++ b/src/sliceview/backend_chunk_decoders/png.ts @@ -33,8 +33,9 @@ export async function decodePngChunk( cancellationToken, [response], /*buffer=*/ new Uint8Array(response), - /*width=*/ chunkDataSize[0], - /*height=*/ chunkDataSize[1] * chunkDataSize[2], + /*width=*/ undefined, + /*height=*/ undefined, + /*area=*/ chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2], /*numComponents=*/ chunkDataSize[3] || 1, /*bytesPerPixel=*/ DATA_TYPE_BYTES[dataType], /*convertToGrayscale=*/ false, diff --git a/src/sliceview/jxl/Cargo.toml b/src/sliceview/jxl/Cargo.toml new file mode 100644 index 000000000..344f67899 --- /dev/null +++ b/src/sliceview/jxl/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jxl-wasm" +version = "1.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +jxl-oxide = "0.9.1" + +[profile.release] +lto = true + +[package.metadata.wasm-opt] +memory = { initial = 10, maximum = 100 } # Set initial and max memory in MiB diff --git a/src/sliceview/jxl/Dockerfile b/src/sliceview/jxl/Dockerfile new file mode 100644 index 000000000..d7adaf55d --- /dev/null +++ b/src/sliceview/jxl/Dockerfile @@ -0,0 +1,5 @@ +FROM rust:slim-bullseye + +RUN rustup target add wasm32-unknown-unknown + + diff --git a/src/sliceview/jxl/build.sh b/src/sliceview/jxl/build.sh new file mode 100755 index 000000000..674e31e4f --- /dev/null +++ b/src/sliceview/jxl/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash -xve + +# This script builds `jxl_decoder.wasm` using emsdk in a docker container. + +cd "$(dirname "$0")" + +docker build . +docker run \ + --rm \ + -v ${PWD}:/src \ + -u $(id -u):$(id -g) \ + $(docker build -q .) \ + /src/build_wasm.sh diff --git a/src/sliceview/jxl/build_wasm.sh b/src/sliceview/jxl/build_wasm.sh new file mode 100755 index 000000000..1bc9daf49 --- /dev/null +++ b/src/sliceview/jxl/build_wasm.sh @@ -0,0 +1,6 @@ +#!/bin/bash -xve + +cd /src +cargo build --target wasm32-unknown-unknown --release +cp /src/target/wasm32-unknown-unknown/release/jxl_wasm.wasm /src/jxl_decoder.wasm +rm -r target \ No newline at end of file diff --git a/src/sliceview/jxl/index.ts b/src/sliceview/jxl/index.ts new file mode 100644 index 000000000..b41f3fba8 --- /dev/null +++ b/src/sliceview/jxl/index.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2024 William Silvermsith + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DecodedImage } from "#src/async_computation/decode_png_request.js"; + +const libraryEnv = {}; + +let jxlModulePromise: Promise | undefined; + +async function getJxlModulePromise() { + if (jxlModulePromise === undefined) { + jxlModulePromise = (async () => { + const m = ( + await WebAssembly.instantiateStreaming( + fetch(new URL("./jxl_decoder.wasm", import.meta.url)), + { + env: libraryEnv, + wasi_snapshot_preview1: libraryEnv, + }, + ) + ).instance; + return m; + })(); + } + return jxlModulePromise; +} + +// header constants +// obtained from +// // https://github.com/libjxl/libjxl/blob/8f22cb1fb98ed27ceee59887bd291ef4d277c89d/lib/jxl/decode.cc#L118-L130 +const magicSpec = [ + 0, + 0, + 0, + 0xc, + "J".charCodeAt(0), + "X".charCodeAt(0), + "L".charCodeAt(0), + " ".charCodeAt(0), + 0xd, + 0xa, + 0x87, + 0xa, +]; + +// not a full implementation of read header, just the parts we need +function checkHeader(buffer: Uint8Array) { + function arrayEqualTrucated(a: any, b: any): boolean { + return a.every((val: number, idx: number) => val === b[idx]); + } + + const len = buffer.length; + const kCodestreamMarker = 0x0a; + + if (len < 8 + 4) { + throw new Error(`jxl: Invalid image size: ${len}`); + } + + // JPEG XL codestream: 0xff 0x0a + if (len >= 1 && buffer[0] === 0xff) { + if (len < 2) { + throw new Error(`jxl: Not enough bytes. Got: ${len}`); + } else if (buffer[1] === kCodestreamMarker) { + // valid codestream + return; + } else { + throw new Error(`jxl: Invalid codestream.`); + } + } + + // JPEG XL container + // check for header for magic sequence + const validMagic = arrayEqualTrucated(magicSpec, buffer); + if (!validMagic) { + throw new Error(`jxl: didn't match magic numbers: ${buffer.slice(0, 12)}`); + } +} + +export async function decompressJxl( + buffer: Uint8Array, + area: number | undefined, + numComponents: number | undefined, + bytesPerPixel: number, +): Promise { + const m = await getJxlModulePromise(); + checkHeader(buffer); + + area ||= 0; + numComponents ||= 1; + + const nbytes = area * bytesPerPixel * numComponents; + + const jxlImagePtr = (m.exports.malloc as Function)(buffer.byteLength); + const heap = new Uint8Array((m.exports.memory as WebAssembly.Memory).buffer); + heap.set(buffer, jxlImagePtr); + + let imagePtr = null; + + try { + const width = (m.exports.width as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + ); + const height = (m.exports.height as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + ); + + if (width <= 0 || height <= 0) { + throw new Error( + `jxl: Decoding failed. Width (${width}) and/or height (${height}) invalid.`, + ); + } + + if (area !== undefined && width * height !== area) { + throw new Error( + `jxl: Expected width and height (${width} x ${height}, ${width * height}) to match area: ${area}.`, + ); + } + + imagePtr = (m.exports.decode as Function)( + jxlImagePtr, + buffer.byteLength, + nbytes, + ); + + if (imagePtr === 0) { + throw new Error("jxl: Decoding failed. Null pointer returned."); + } + + // Likewise, we reference memory.buffer instead of heap.buffer + // because memory growth during decompress could have detached + // the buffer. + const image = new Uint8Array( + (m.exports.memory as WebAssembly.Memory).buffer, + imagePtr, + nbytes, + ); + + // copy the array so it can be memory managed by JS + // and we can free the emscripten buffer + return { + width: width || 0, + height: height || 0, + numComponents: numComponents || 1, + uint8Array: image.slice(0), + }; + } finally { + (m.exports.free as Function)(jxlImagePtr, buffer.byteLength); + if (imagePtr) { + (m.exports.free as Function)(imagePtr, nbytes); + } + } +} diff --git a/src/sliceview/jxl/jxl_decoder.wasm b/src/sliceview/jxl/jxl_decoder.wasm new file mode 100755 index 000000000..d5c3b161c Binary files /dev/null and b/src/sliceview/jxl/jxl_decoder.wasm differ diff --git a/src/sliceview/jxl/src/lib.rs b/src/sliceview/jxl/src/lib.rs new file mode 100644 index 000000000..5b9bbf63a --- /dev/null +++ b/src/sliceview/jxl/src/lib.rs @@ -0,0 +1,147 @@ +use std::ptr; +use std::alloc::{alloc, dealloc, Layout}; +use std::slice; + +use jxl_oxide::{FrameBuffer, JxlImage, PixelFormat}; + +#[no_mangle] +pub fn malloc(size: usize) -> *mut u8 { + let layout = Layout::from_size_align(size, std::mem::align_of::()).unwrap(); + unsafe { + let ptr = alloc(layout); + if ptr.is_null() { + panic!("Memory allocation failed"); + } + ptr + } +} + +#[no_mangle] +pub fn free(ptr: *mut u8, size: usize) { + let layout = Layout::from_size_align(size, std::mem::align_of::()).unwrap(); + unsafe { + dealloc(ptr, layout); + } +} + +#[no_mangle] +pub fn width(ptr: *mut u8, input_size: usize, output_size: usize) -> i32 { + if ptr.is_null() || input_size == 0 || output_size == 0 { + return -1; + } + + let data: &[u8] = unsafe { + slice::from_raw_parts(ptr, input_size) + }; + + let image = match JxlImage::builder().read(data) { + Ok(image) => image, + Err(_image) => return -2, + }; + + for keyframe_idx in 0..image.num_loaded_keyframes() { + let frame = match image.render_frame(keyframe_idx) { + Ok(frame) => frame, + Err(_frame) => return -3, + }; + + let stream = frame.stream(); + return stream.width() as i32; + } + + -4 as i32 +} + +#[no_mangle] +pub fn height(ptr: *mut u8, input_size: usize, output_size: usize) -> i32 { + if ptr.is_null() || input_size == 0 || output_size == 0 { + return -1; + } + + let data: &[u8] = unsafe { + slice::from_raw_parts(ptr, input_size) + }; + + let image = match JxlImage::builder().read(data) { + Ok(image) => image, + Err(_image) => return -2, + }; + + for keyframe_idx in 0..image.num_loaded_keyframes() { + let frame = match image.render_frame(keyframe_idx) { + Ok(frame) => frame, + Err(_frame) => return -3, + }; + + let stream = frame.stream(); + return stream.height() as i32; + } + + -4 as i32 +} + +#[no_mangle] +pub fn decode(ptr: *mut u8, input_size: usize, output_size: usize) -> *const u8 { + if ptr.is_null() || input_size == 0 || output_size == 0 { + return ptr::null(); + } + + let data: &[u8] = unsafe { + slice::from_raw_parts(ptr, input_size) + }; + + let image = match JxlImage::builder().read(data) { + Ok(image) => image, + Err(_image) => return std::ptr::null_mut(), + }; + + let mut output_buffer = Vec::with_capacity(output_size); + + for keyframe_idx in 0..image.num_loaded_keyframes() { + let frame = match image.render_frame(keyframe_idx) { + Ok(frame) => frame, + Err(_frame) => return std::ptr::null_mut(), + }; + + let mut stream = frame.stream(); + let mut fb = FrameBuffer::new( + stream.width() as usize, + stream.height() as usize, + stream.channels() as usize, + ); + stream.write_to_buffer(fb.buf_mut()); + + match image.pixel_format() { + PixelFormat::Gray => { + for pixel in fb.buf() { + let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(value); + } + }, + PixelFormat::Rgb => { + for pixel in fb.buf() { + let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(value); + } + } + PixelFormat::Rgba => { + for pixel in fb.buf() { + let value = (pixel * 255.0).clamp(0.0, 255.0) as u8; + output_buffer.push(value); + output_buffer.push(255); // Alpha channel set to fully opaque + } + } + _ => return std::ptr::null_mut(), + } + } + + // Allocate memory in WASM and return a pointer and length + let ptr = output_buffer.as_ptr(); + + // Ensure that the memory is not dropped until after we return + std::mem::forget(output_buffer); + + ptr +} + + diff --git a/src/sliceview/png/index.ts b/src/sliceview/png/index.ts index 86a25797f..c138aca23 100644 --- a/src/sliceview/png/index.ts +++ b/src/sliceview/png/index.ts @@ -71,13 +71,13 @@ function readHeader(buffer: Uint8Array): { } if (buffer.length < 8 + 4) { - throw new Error("png: Invalid image size: {buffer.length}"); + throw new Error(`png: Invalid image size: ${buffer.length}`); } // check for header for magic sequence const validMagic = arrayEqualTrucated(magicSpec, buffer); if (!validMagic) { - throw new Error(`png: didn't match magic numbers: {buffer.slice(0,8)}`); + throw new Error(`png: didn't match magic numbers: ${buffer.slice(0, 8)}`); } // offset into IHDR chunk so we can read more naturally @@ -86,7 +86,7 @@ function readHeader(buffer: Uint8Array): { const chunkHeaderLength = 12; // len (4), code (4), CRC (4) if (buffer.length < magicSpec.length + chunkLength + chunkHeaderLength) { - throw new Error("png: Invalid image size: {buffer.length}"); + throw new Error(`png: Invalid image size: ${buffer.length}`); } const chunkCode = [4, 5, 6, 7].map((i) => @@ -172,6 +172,7 @@ export async function decompressPng( buffer: Uint8Array, width: number | undefined, height: number | undefined, + area: number | undefined, numComponents: number | undefined, bytesPerPixel: number, convertToGrayscale: boolean, @@ -187,15 +188,17 @@ export async function decompressPng( if ( (width !== undefined && sx !== width) || (height !== undefined && sy !== height) || + (area !== undefined && sx * sy !== area) || (numComponents !== undefined && numComponents !== numChannels) || bytesPerPixel !== dataWidth ) { throw new Error( - `png: Image decode parameters did not match expected chunk parameters. - Expected: width: ${width} height: ${height} channels: ${numComponents} bytes per pixel: ${bytesPerPixel} - Decoded: width: ${sx} height: ${sy} channels: ${numChannels} bytes per pixel: ${dataWidth} - Convert to Grayscale? ${convertToGrayscale} - `, + `png: Image decode parameters did not match expected chunk parameters. ` + + `Expected: width: ${width} height: ${height} area: ${area} ` + + `channels: ${numComponents} bytes per pixel: ${bytesPerPixel}. ` + + `Decoded: width: ${sx} height: ${sy} channels: ${numChannels} ` + + `bytes per pixel: ${dataWidth}. ` + + `Convert to Grayscale? ${convertToGrayscale}`, ); } diff --git a/src/ui/url_hash_binding.ts b/src/ui/url_hash_binding.ts index 48d6e4009..5c338cb5e 100644 --- a/src/ui/url_hash_binding.ts +++ b/src/ui/url_hash_binding.ts @@ -82,6 +82,7 @@ export class UrlHashBinding extends RefCounted { const throttledSetUrlHash = debounce( () => this.setUrlHash(), updateDelayMilliseconds, + { maxWait: updateDelayMilliseconds * 2 }, ); this.registerDisposer(root.changed.add(throttledSetUrlHash)); this.registerDisposer(() => throttledSetUrlHash.cancel()); diff --git a/src/util/empirical_cdf.browser_test.ts b/src/util/empirical_cdf.browser_test.ts new file mode 100644 index 000000000..eb4b7d3e3 --- /dev/null +++ b/src/util/empirical_cdf.browser_test.ts @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { DataType } from "#src/util/data_type.js"; +import { computePercentilesFromEmpiricalHistogram } from "#src/util/empirical_cdf.js"; +import type { DataTypeInterval } from "#src/util/lerp.js"; +import { + dataTypeCompare, + dataTypeIntervalEqual, + defaultDataTypeRange, +} from "#src/util/lerp.js"; +import { Uint64 } from "#src/util/uint64.js"; + +// The first and last bin are for values below the lower bound/above the upper +// To simulate output from the GLSL shader function on CPU +function countDataInBins( + inputData: (number | Uint64)[], + dataType: DataType, + min: number | Uint64, + max: number | Uint64, + numDataBins: number = 254, +): Float32Array { + // Total number of bins is numDataBins + 2, one for values below the lower + // bound and one for values above the upper bound. + const counts = new Float32Array(numDataBins + 2).fill(0); + let binSize: number; + let binIndex: number; + const numerator64 = new Uint64(); + if (dataType === DataType.UINT64) { + Uint64.subtract(numerator64, max as Uint64, min as Uint64); + binSize = numerator64.toNumber() / numDataBins; + } else { + binSize = ((max as number) - (min as number)) / numDataBins; + } + for (let i = 0; i < inputData.length; i++) { + const value = inputData[i]; + if (dataTypeCompare(value, min) < 0) { + counts[0]++; + } else if (dataTypeCompare(value, max) > 0) { + counts[numDataBins + 1]++; + } else { + if (dataType === DataType.UINT64) { + Uint64.subtract(numerator64, value as Uint64, min as Uint64); + binIndex = Math.floor(numerator64.toNumber() / binSize); + } else { + binIndex = Math.floor(((value as number) - (min as number)) / binSize); + } + counts[binIndex + 1]++; + } + } + return counts; +} + +describe("empirical_cdf", () => { + // 0 to 100 inclusive + { + const dataRange = [0, 100] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`Calculates min and max for ${DataType[dataType]} on range ${dataRange}`, () => { + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + ); + checkPercentileAccuracy(dataRange, range, dataType); + }); + } + } + + // 100 to 125 inclusive + { + const dataRange = [100, 125] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`Calculates min and max for ${DataType[dataType]} on range ${dataRange}`, () => { + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + ); + checkPercentileAccuracy(dataRange, range, dataType); + }); + } + } + + // Try larger values and exclude low bit data types + { + const dataRange = [28791, 32767] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + if (dataType === DataType.UINT8 || dataType === DataType.INT8) continue; + it(`Calculates min and max for ${DataType[dataType]} on range ${dataRange}`, () => { + const tolerance = (dataRange[1] - dataRange[0] + 1) / 254; + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + ); + checkPercentileAccuracy(dataRange, range, dataType, 0, 1, tolerance); + }); + } + } + + // 1 - 99 percentile over 0-100 + { + const dataRange = [0, 100] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`Calculates 1-99% for ${DataType[dataType]} on range ${dataRange}`, () => { + const minPercentile = 0.01; + const maxPercentile = 0.99; + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + minPercentile, + maxPercentile, + ); + checkPercentileAccuracy( + dataRange, + range, + dataType, + minPercentile, + maxPercentile, + ); + }); + } + } + + // 5 - 95 percentile over 0-100 + { + const dataRange = [0, 100] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + it(`Calculates 5-95% for ${DataType[dataType]} on range ${dataRange}`, () => { + const minPercentile = 0.05; + const maxPercentile = 0.95; + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + minPercentile, + maxPercentile, + ); + checkPercentileAccuracy( + dataRange, + range, + dataType, + minPercentile, + maxPercentile, + ); + }); + } + } + + // Large data values on 5-95 percentile + { + const dataRange = [28791, 32767] as [number, number]; + const dataValues = generateSequentialArray(dataRange); + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + if (dataType === DataType.UINT8 || dataType === DataType.INT8) continue; + it(`Calcalates 5-95% for ${DataType[dataType]} on range ${dataRange}`, () => { + const minPercentile = 0.05; + const maxPercentile = 0.95; + const tolerance = (dataRange[1] - dataRange[0] + 1) / 254; + const range = findPercentilesFromIterativeHistogram( + dataValues, + dataType, + minPercentile, + maxPercentile, + ); + checkPercentileAccuracy( + dataRange, + range, + dataType, + minPercentile, + maxPercentile, + tolerance, + ); + }); + } + } + + function generateSequentialArray(dataRange: [number, number]) { + return Array.from( + { length: dataRange[1] - dataRange[0] + 1 }, + (_, i) => i + dataRange[0], + ); + } +}); + +function determineInitialDataRange(dataType: DataType) { + return dataType === DataType.FLOAT32 + ? ([-10000, 10000] as [number, number]) + : defaultDataTypeRange[dataType]; +} + +function findPercentilesFromIterativeHistogram( + inputDataValues: number[], + inputDataType: DataType, + minPercentile = 0.0, + maxPercentile = 1.0, +) { + const data = + inputDataType === DataType.UINT64 + ? inputDataValues.map((v) => Uint64.fromNumber(v)) + : inputDataValues; + let numIterations = 0; + const startRange = determineInitialDataRange(inputDataType); + let oldRange = startRange; + let newRange = startRange; + do { + const binCounts = countDataInBins( + data, + inputDataType, + newRange[0], + newRange[1], + ); + oldRange = newRange; + newRange = computePercentilesFromEmpiricalHistogram( + binCounts, + minPercentile, + maxPercentile, + newRange, + inputDataType, + ).range; + ++numIterations; + } while ( + !dataTypeIntervalEqual(inputDataType, oldRange, newRange) && + numIterations < 32 + ); + expect(numIterations, "Too many iterations").toBeLessThan(16); + return newRange; +} + +function checkPercentileAccuracy( + actualDataRange: [number, number], + computedPercentiles: DataTypeInterval, + inputDataType: DataType, + minPercentile: number = 0.0, + maxPercentile: number = 1.0, + tolerance: number = 0, +) { + const min = + inputDataType === DataType.UINT64 + ? (computedPercentiles[0] as Uint64).toNumber() + : (computedPercentiles[0] as number); + const max = + inputDataType === DataType.UINT64 + ? (computedPercentiles[1] as Uint64).toNumber() + : (computedPercentiles[1] as number); + const diff = actualDataRange[1] - actualDataRange[0]; + const correctRange = [ + actualDataRange[0] + minPercentile * diff, + actualDataRange[0] + maxPercentile * diff, + ]; + expect( + Math.abs(Math.round(min - correctRange[0])), + `Got lower bound ${min} expected ${correctRange[0]}`, + ).toBeLessThanOrEqual(tolerance); + expect( + Math.abs(Math.round(max - correctRange[1])), + `Got upper bound ${max} expected ${correctRange[1]}`, + ).toBeLessThanOrEqual(tolerance); +} diff --git a/src/util/empirical_cdf.ts b/src/util/empirical_cdf.ts new file mode 100644 index 000000000..e11a6c0ed --- /dev/null +++ b/src/util/empirical_cdf.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + + * @file Defines facilities for manipulation of empirical cumulative distribution functions. + */ +import { DataType } from "#src/util/data_type.js"; +import { + clampToInterval, + defaultDataTypeRange, + type DataTypeInterval, +} from "#src/util/lerp.js"; +import { Uint64 } from "#src/util/uint64.js"; + +const BIN_SIZE_MULTIPLIER_FOR_WINDOW = 64; + +interface AutoRangeResult { + range: DataTypeInterval; + /** The window is set a bit larger than the range */ + window: DataTypeInterval; +} + +function calculateEmpiricalCdf(histogram: Float32Array): Float32Array | null { + const totalSamples = histogram.reduce((a, b) => a + b, 0); + if (totalSamples === 0) { + return null; + } + let cumulativeCount = 0; + const empiricalCdf = histogram.map((count) => { + cumulativeCount += count; + return cumulativeCount / totalSamples; + }); + return empiricalCdf; +} + +function calculateBinSize( + histogram: Float32Array, + histogramRange: DataTypeInterval, + inputDataType: DataType, +): number { + const totalBins = histogram.length - 2; // Exclude the first and last bins. + if (inputDataType === DataType.UINT64) { + const numerator64 = new Uint64(); + const min = histogramRange[0] as Uint64; + const max = histogramRange[1] as Uint64; + Uint64.subtract(numerator64, max, min); + return numerator64.toNumber() / totalBins; + } else { + const min = histogramRange[0] as number; + const max = histogramRange[1] as number; + return (max - min) / totalBins; + } +} + +function adjustBound( + bound: number | Uint64, + dataType: DataType, + change: number, + increase: boolean, +): number | Uint64 { + const maxDataRange = defaultDataTypeRange[dataType]; + + // If the bound is already at the limit, don't adjust it. + if (dataType !== DataType.FLOAT32) { + const boundLimit = increase ? maxDataRange[1] : maxDataRange[0]; + if (bound === boundLimit) { + return bound; + } + } + + // Adjust the bound by the change amount up or down. + const delta = dataType === DataType.FLOAT32 ? change : Math.round(change); + const temp = new Uint64(); + const adjustedBound = + dataType === DataType.UINT64 + ? increase + ? Uint64.add(temp, bound as Uint64, Uint64.fromNumber(delta)) + : Uint64.subtract(temp, bound as Uint64, Uint64.fromNumber(delta)) + : increase + ? (bound as number) + delta + : (bound as number) - delta; + + // Ensure the bound is within the data type's range. + if (dataType === DataType.FLOAT32) { + return adjustedBound; + } + return clampToInterval(maxDataRange, adjustedBound); +} + +function decreaseBound( + bound: number | Uint64, + dataType: DataType, + change: number, +): number | Uint64 { + return adjustBound(bound, dataType, change, false); +} + +function increaseBound( + bound: number | Uint64, + dataType: DataType, + change: number, +): number | Uint64 { + return adjustBound(bound, dataType, change, true); +} + +export function expandRange( + range: DataTypeInterval, + inputDataType: DataType, + expansionAmount: number = 0, +): DataTypeInterval { + if (inputDataType === DataType.UINT64 && expansionAmount !== 1) { + return range; + } + const lowerBound = range[0]; + const upperBound = range[1]; + const expandedRange = [ + decreaseBound(lowerBound, inputDataType, expansionAmount), + increaseBound(upperBound, inputDataType, expansionAmount), + ] as DataTypeInterval; + return expandedRange; +} + +export function computePercentilesFromEmpiricalHistogram( + histogram: Float32Array, + lowerPercentile: number = 0.05, + upperPercentile: number = 0.95, + histogramRange: DataTypeInterval, + inputDataType: DataType, +): AutoRangeResult { + // 256 bins total. First and last bin are below lower bound/above upper. + let lowerBound = histogramRange[0]; + let upperBound = histogramRange[1]; + const cdf = calculateEmpiricalCdf(histogram); + if (cdf === null) { + return { range: histogramRange, window: histogramRange }; + } + const binSize = calculateBinSize(histogram, histogramRange, inputDataType); + + // Find the indices of the percentiles. + let lowerIndex = 0; + for (let i = 0; i < cdf.length; i++) { + lowerIndex = i; + if (cdf[i] > lowerPercentile) { + break; + } + } + let upperIndex = cdf.findIndex((cdfValue) => cdfValue >= upperPercentile); + upperIndex = upperIndex === -1 ? histogram.length - 1 : upperIndex; + + // If the percentile is off the histogram to the left, the lower + // bound will be decreased to include more data. + if (lowerIndex === 0) { + let shiftAmount = binSize / 2; + if (inputDataType === DataType.FLOAT32) { + shiftAmount = Math.max( + shiftAmount, + Math.max(1, Math.abs((lowerBound as number) / 2)), + ); + } + lowerBound = decreaseBound(lowerBound, inputDataType, shiftAmount); + } else { + // Otherwise, the lower bound is either exactly correct, and not moved + // or it could be moved to the right to include less data. + const shiftAmount = lowerIndex - 1; // Exclude the first bin. + lowerBound = increaseBound( + lowerBound, + inputDataType, + binSize * shiftAmount, + ); + } + + // If the percentile is off the histogram to the right, the upper + // bound will be increased to include more data. + if (upperIndex === histogram.length - 1) { + let shiftAmount = binSize / 2; + if (inputDataType === DataType.FLOAT32) { + shiftAmount = Math.max( + shiftAmount, + Math.max(1, Math.abs((upperBound as number) / 2)), + ); + } + upperBound = increaseBound(upperBound, inputDataType, shiftAmount); + } else { + // Otherwise, the upper bound is either exactly correct, and not moved + // or it could be moved to the left to include less data + const shiftAmount = histogram.length - 2 - upperIndex; // Exclude the first bin. + upperBound = decreaseBound( + upperBound, + inputDataType, + binSize * shiftAmount, + ); + } + + const range = [lowerBound, upperBound] as DataTypeInterval; + // Bump the window out a bit to make it easier to adjust. + let expandAmount = binSize * BIN_SIZE_MULTIPLIER_FOR_WINDOW; + if (inputDataType !== DataType.FLOAT32) { + expandAmount = Math.max(1.0, expandAmount); + } + const window = expandRange(range, inputDataType, expandAmount); + return { range, window }; +} diff --git a/src/util/framerate.ts b/src/util/framerate.ts index 05f34bec2..35eb36108 100644 --- a/src/util/framerate.ts +++ b/src/util/framerate.ts @@ -20,10 +20,22 @@ export enum FrameTimingMethod { MAX = 2, } +interface QueryInfo { + glQuery: WebGLQuery; + frameNumber: number; + wasStarted: boolean; + wasEnded: boolean; +} + +interface FrameDeltaInfo { + frameDelta: number; + frameNumber: number; +} + export class FramerateMonitor { - private timeElapsedQueries: (WebGLQuery | null)[] = []; + private timeElapsedQueries: QueryInfo[] = []; private warnedAboutMissingExtension = false; - private storedTimeDeltas: number[] = []; + private storedTimeDeltas: FrameDeltaInfo[] = []; constructor( private numStoredTimes: number = 10, @@ -48,61 +60,107 @@ export class FramerateMonitor { return ext; } - startFrameTimeQuery(gl: WebGL2RenderingContext, ext: any) { + getOldestQueryIndexByFrameNumber() { + if (this.timeElapsedQueries.length === 0) { + return undefined; + } + let oldestQueryIndex = 0; + for (let i = 1; i < this.timeElapsedQueries.length; i++) { + const oldestQuery = this.timeElapsedQueries[oldestQueryIndex]; + if (this.timeElapsedQueries[i].frameNumber < oldestQuery.frameNumber) { + oldestQueryIndex = i; + } + } + return oldestQueryIndex; + } + + startFrameTimeQuery( + gl: WebGL2RenderingContext, + ext: any, + frameNumber: number, + ) { if (ext === null) { return null; } const query = gl.createQuery(); - if (query !== null) { + const currentQuery = + this.timeElapsedQueries[this.timeElapsedQueries.length - 1]; + if (query !== null && currentQuery !== query) { gl.beginQuery(ext.TIME_ELAPSED_EXT, query); + if (this.timeElapsedQueries.length >= this.queryPoolSize) { + const oldestQueryIndex = this.getOldestQueryIndexByFrameNumber(); + if (oldestQueryIndex !== undefined) { + const oldestQuery = this.timeElapsedQueries.splice( + oldestQueryIndex, + 1, + )[0]; + gl.deleteQuery(oldestQuery.glQuery); + } + } + const queryInfo: QueryInfo = { + glQuery: query, + frameNumber: frameNumber, + wasStarted: true, + wasEnded: false, + }; + this.timeElapsedQueries.push(queryInfo); } return query; } - endFrameTimeQuery( - gl: WebGL2RenderingContext, - ext: any, - query: WebGLQuery | null, - ) { - if (ext !== null && query !== null) { - gl.endQuery(ext.TIME_ELAPSED_EXT); - } - if (this.timeElapsedQueries.length >= this.queryPoolSize) { - const oldestQuery = this.timeElapsedQueries.shift(); - if (oldestQuery !== null && oldestQuery !== undefined) { - gl.deleteQuery(oldestQuery); + endLastTimeQuery(gl: WebGL2RenderingContext, ext: any) { + if (ext !== null) { + const currentQuery = + this.timeElapsedQueries[this.timeElapsedQueries.length - 1]; + if (!currentQuery.wasEnded && currentQuery.wasStarted) { + gl.endQuery(ext.TIME_ELAPSED_EXT); + currentQuery.wasEnded = true; } } - this.timeElapsedQueries.push(query); } grabAnyFinishedQueryResults(gl: WebGL2RenderingContext) { const deletedQueryIndices: number[] = []; for (let i = 0; i < this.timeElapsedQueries.length; i++) { const query = this.timeElapsedQueries[i]; - if (query !== null) { + // Error checking: if the query was not started or ended, just delete it. + // This can happen from errors in the rendering + if (!query.wasEnded || !query.wasStarted) { + gl.deleteQuery(query.glQuery); + deletedQueryIndices.push(i); + } else { const available = gl.getQueryParameter( - query, + query.glQuery, gl.QUERY_RESULT_AVAILABLE, ); - if (available) { - const result = gl.getQueryParameter(query, gl.QUERY_RESULT) / 1e6; - this.storedTimeDeltas.push(result); - gl.deleteQuery(query); + // If the result is null, then something went wrong and we should just delete the query. + if (available === null) { + gl.deleteQuery(query.glQuery); + deletedQueryIndices.push(i); + } else if (available) { + const result = + gl.getQueryParameter(query.glQuery, gl.QUERY_RESULT) / 1e6; + this.storedTimeDeltas.push({ + frameDelta: result, + frameNumber: query.frameNumber, + }); + gl.deleteQuery(query.glQuery); deletedQueryIndices.push(i); } } } - for (let i = deletedQueryIndices.length - 1; i >= 0; i--) { - this.timeElapsedQueries.splice(i, 1); - } + this.timeElapsedQueries = this.timeElapsedQueries.filter( + (_, i) => !deletedQueryIndices.includes(i), + ); if (this.storedTimeDeltas.length > this.numStoredTimes) { this.storedTimeDeltas = this.storedTimeDeltas.slice(-this.numStoredTimes); } } getLastFrameTimesInMs(numberOfFrames: number = 10) { - return this.storedTimeDeltas.slice(-numberOfFrames); + return this.storedTimeDeltas + .slice(-numberOfFrames) + .map((frameDeltaInfo) => frameDeltaInfo.frameDelta); } getQueries() { diff --git a/src/volume_rendering/volume_render_layer.ts b/src/volume_rendering/volume_render_layer.ts index f7b68bfed..ee6064b67 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -113,7 +113,6 @@ const HISTOGRAM_SAMPLES_PER_INSTANCE = 256; // Number of points to sample in computing the histogram. Increasing this increases the precision // of the histogram but also slows down rendering. -// Here, we use 4096 samples per chunk to compute the histogram. const NUM_HISTOGRAM_SAMPLES = 2 ** 14; const DEBUG_HISTOGRAMS = false; @@ -160,7 +159,6 @@ interface StoredChunkDataForMultipass { fixedPositionWithinChunk: Uint32Array; chunkDisplayDimensionIndices: number[]; channelToChunkDimensionIndices: readonly number[]; - chunkDataDisplaySize: vec3; chunkFormat: ChunkFormat | null | undefined; } @@ -630,15 +628,10 @@ float getHistogramValue${i}() { simpleFloatHash(vec2(aInput1 + float(gl_VertexID) + 20.0, 15.0 + float(gl_InstanceID)))); chunkSamplePosition = rand3val * (uChunkDataSize - 1.0); ${histogramFetchCode} - if (x == 0.0) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - } - else { - if (x < 0.0) x = 0.0; - else if (x > 1.0) x = 1.0; - else x = (1.0 + x * 253.0) / 255.0; - gl_Position = vec4(2.0 * (x * 255.0 + 0.5) / 256.0 - 1.0, 0.0, 0.0, 1.0); - } + if (x < 0.0) x = 0.0; + else if (x > 1.0) x = 1.0; + else x = (1.0 + x * 253.0) / 255.0; + gl_Position = vec4(2.0 * (x * 255.0 + 0.5) / 256.0 - 1.0, 0.0, 0.0, 1.0); gl_PointSize = 1.0;`); builder.setFragmentMain(` outputValue = vec4(1.0, 1.0, 1.0, 1.0); @@ -851,7 +844,8 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); this.getDataHistogramCount() > 0 && !renderContext.wireFrame && !renderContext.sliceViewsPresent && - !renderContext.isContinuousCameraMotionInProgress; + (!renderContext.isContinuousCameraMotionInProgress || + renderContext.force3DHistogramForAutoRange); const needPickingPass = !isProjectionMode(this.mode.value) && !renderContext.isContinuousCameraMotionInProgress && @@ -1019,11 +1013,8 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); fixedPositionWithinChunk, chunkDisplayDimensionIndices, channelToChunkDimensionIndices, - chunkDataDisplaySize, chunkFormat: prevChunkFormat, }); - } - if (needPickingPass) { const copiedDisplaySize = vec3.create(); const copiedPosition = vec3.create(); vec3.copy(copiedDisplaySize, chunkDataDisplaySize); @@ -1133,21 +1124,15 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); }; const determineNumHistogramInstances = ( chunkDataSize: vec3, - numHistograms: number, + totalChunkVolume: number, ) => { - const maxSamplesInChunk = Math.ceil( - chunkDataSize.reduce((a, b) => a * b, 1) / 2.0, - ); - const totalDesiredSamplesInChunk = - NUM_HISTOGRAM_SAMPLES / numHistograms; - const desiredSamples = Math.min( - maxSamplesInChunk, - totalDesiredSamplesInChunk, - ); - - // round to nearest multiple of NUM_HISTOGRAM_SAMPLES_PER_INSTANCE + const chunkVolume = chunkDataSize.reduce((a, b) => a * b, 1); + const desiredChunkSamples = + NUM_HISTOGRAM_SAMPLES * (chunkVolume / totalChunkVolume); + const maxSamplesInChunk = chunkVolume / 2.0; + const clampedSamples = Math.min(maxSamplesInChunk, desiredChunkSamples); return Math.max( - Math.round(desiredSamples / HISTOGRAM_SAMPLES_PER_INSTANCE), + Math.round(clampedSamples / HISTOGRAM_SAMPLES_PER_INSTANCE), 1, ); }; @@ -1166,9 +1151,22 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); // Blending on to accumulate histograms. gl.enable(WebGL2RenderingContext.BLEND); gl.disable(WebGL2RenderingContext.DEPTH_TEST); + + const totalChunkVolume = shaderUniformsForSecondPass.reduce( + (sum, uniforms) => { + const chunkVolume = uniforms.uChunkDataSize.reduce( + (a, b) => a * b, + 1, + ); + return sum + chunkVolume; + }, + 0, + ); + for (let j = 0; j < presentCount; ++j) { newSource = true; const chunkInfo = chunkInfoForMultipass[j]; + const uniforms = shaderUniformsForSecondPass[j]; const chunkFormat = chunkInfo.chunkFormat; if (chunkFormat !== prevChunkFormat) { prevChunkFormat = chunkFormat; @@ -1190,7 +1188,7 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); if (histogramShader === null) break; gl.uniform3fv( histogramShader.uniform("uChunkDataSize"), - chunkInfo.chunkDataDisplaySize, + uniforms.uChunkDataSize, ); if (prevChunkFormat != null) { prevChunkFormat.bindChunk( @@ -1212,8 +1210,8 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); // Draw each histogram const numInstances = determineNumHistogramInstances( - chunkInfo.chunkDataDisplaySize, - presentCount, + uniforms.uChunkDataSize, + totalChunkVolume, ); for (let i = 0; i < numHistograms; ++i) { histogramFramebuffers[i].bind(256, 1); diff --git a/src/webgl/empirical_cdf.ts b/src/webgl/empirical_cdf.ts index 799fe9812..71c35148e 100644 --- a/src/webgl/empirical_cdf.ts +++ b/src/webgl/empirical_cdf.ts @@ -209,23 +209,28 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); ); if (DEBUG_HISTOGRAMS) { - const tempBuffer = new Float32Array(256 * 4); - gl.readPixels( - 0, - 0, - 256, - 1, - WebGL2RenderingContext.RGBA, - WebGL2RenderingContext.FLOAT, - tempBuffer, - ); - const tempBuffer2 = new Float32Array(256); - for (let j = 0; j < 256; ++j) { - tempBuffer2[j] = tempBuffer[j * 4]; - } - console.log("histogram", tempBuffer2.join(" ")); + const tempBuffer = copyHistogramToCPU(gl); + console.log("histogram", tempBuffer.join(" ")); } } gl.disable(WebGL2RenderingContext.BLEND); } } + +export function copyHistogramToCPU(gl: GL) { + const tempBuffer = new Float32Array(256 * 4); + gl.readPixels( + 0, + 0, + 256, + 1, + WebGL2RenderingContext.RGBA, + WebGL2RenderingContext.FLOAT, + tempBuffer, + ); + const tempBuffer2 = new Float32Array(256); + for (let j = 0; j < 256; ++j) { + tempBuffer2[j] = tempBuffer[j * 4]; + } + return tempBuffer2; +} diff --git a/src/webgl/shader_ui_controls.browser_test.ts b/src/webgl/shader_ui_controls.browser_test.ts index a5cea04d8..759d109f7 100644 --- a/src/webgl/shader_ui_controls.browser_test.ts +++ b/src/webgl/shader_ui_controls.browser_test.ts @@ -899,56 +899,6 @@ void main() { ]), }); }); - it("creates a default transfer function if no control points are provided", () => { - const code = ` -#uicontrol transferFunction colormap(window=[30, 200]) -void main() { -} -`; - const newCode = ` - -void main() { -} -`; - const window = [30, 200]; - const firstInput = window[0] + (window[1] - window[0]) * 0.4; - const secondInput = window[0] + (window[1] - window[0]) * 0.7; - const controlPoints = [ - new ControlPoint(Math.round(firstInput), vec4.fromValues(0, 0, 0, 0)), - new ControlPoint( - Math.round(secondInput), - vec4.fromValues(255, 255, 255, 255), - ), - ]; - const sortedControlPoints = new SortedControlPoints( - controlPoints, - DataType.UINT8, - ); - expect( - parseShaderUiControls(code, { - imageData: { dataType: DataType.UINT8, channelRank: 0 }, - }), - ).toEqual({ - source: code, - code: newCode, - errors: [], - controls: new Map([ - [ - "colormap", - { - type: "transferFunction", - dataType: DataType.UINT8, - default: { - sortedControlPoints: sortedControlPoints, - channel: [], - defaultColor: vec3.fromValues(1, 1, 1), - window, - }, - }, - ], - ]), - }); - }); }); describe("parseTransferFunctionParameters", () => { diff --git a/src/webgl/shader_ui_controls.ts b/src/webgl/shader_ui_controls.ts index 98ebde8a8..4feb3051d 100644 --- a/src/webgl/shader_ui_controls.ts +++ b/src/webgl/shader_ui_controls.ts @@ -34,7 +34,7 @@ import { } from "#src/util/color.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import { kZeroVec4, vec3, vec4 } from "#src/util/geom.js"; +import { vec3, vec4 } from "#src/util/geom.js"; import { parseArray, parseFixedLengthArray, @@ -46,7 +46,6 @@ import { } from "#src/util/json.js"; import type { DataTypeInterval } from "#src/util/lerp.js"; import { - computeLerp, convertDataTypeInterval, dataTypeIntervalToJson, defaultDataTypeRange, @@ -602,7 +601,6 @@ function parseTransferFunctionDirective( [], dataType !== undefined ? dataType : DataType.FLOAT32, ); - let specifedPoints = false; if (valueType !== "transferFunction") { errors.push("type must be transferFunction"); } @@ -629,7 +627,6 @@ function parseTransferFunctionDirective( break; } case "controlPoints": { - specifedPoints = true; if (dataType !== undefined) { sortedControlPoints = parseTransferFunctionControlPoints( value, @@ -650,19 +647,6 @@ function parseTransferFunctionDirective( if (window === undefined) { window = sortedControlPoints.range; } - // Set a simple black to white transfer function if no control points are specified. - if ( - sortedControlPoints.length === 0 && - !specifedPoints && - dataType !== undefined - ) { - const startPoint = computeLerp(window, dataType, 0.4) as number; - const endPoint = computeLerp(window, dataType, 0.7) as number; - sortedControlPoints.addPoint(new ControlPoint(startPoint, kZeroVec4)); - sortedControlPoints.addPoint( - new ControlPoint(endPoint, vec4.fromValues(255, 255, 255, 255)), - ); - } if (errors.length > 0) { return { errors }; } diff --git a/src/widget/icon.css b/src/widget/icon.css index 0e82a0a6b..6a8126333 100644 --- a/src/widget/icon.css +++ b/src/widget/icon.css @@ -42,6 +42,7 @@ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; + pointer-events: none; } .neuroglancer-icon:hover { diff --git a/src/widget/invlerp.ts b/src/widget/invlerp.ts index 336ab3c4a..2543f49cf 100644 --- a/src/widget/invlerp.ts +++ b/src/widget/invlerp.ts @@ -69,6 +69,7 @@ import type { InvlerpParameters } from "#src/webgl/shader_ui_controls.js"; import { getSquareCornersBuffer } from "#src/webgl/square_corners_buffer.js"; import { setRawTextureParameters } from "#src/webgl/texture.js"; import { makeIcon } from "#src/widget/icon.js"; +import { AutoRangeFinder } from "#src/widget/invlerp_range_finder.js"; import type { LayerControlTool } from "#src/widget/layer_control.js"; import type { LegendShaderOptions } from "#src/widget/shader_controls.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -734,6 +735,7 @@ export class InvlerpWidget extends Tab { window: createRangeBoundInputs("window", this.dataType, this.trackable), }; invertArrows: HTMLElement[]; + autoRangeFinder: AutoRangeFinder; get texture() { return this.histogramSpecifications.getFramebuffers(this.display.gl)[ this.histogramIndex @@ -776,6 +778,7 @@ export class InvlerpWidget extends Tab { element.appendChild(this.cdfPanel.element); element.classList.add("neuroglancer-invlerp-widget"); element.appendChild(boundElements.window.container); + this.autoRangeFinder = this.registerDisposer(new AutoRangeFinder(this)); this.updateView(); this.registerDisposer( trackable.changed.add( @@ -784,6 +787,11 @@ export class InvlerpWidget extends Tab { ), ), ); + this.registerDisposer( + this.display.updateFinished.add(() => { + this.autoRangeFinder.maybeAutoComputeRange(); + }), + ); } updateView() { diff --git a/src/widget/invlerp_range_finder.css b/src/widget/invlerp_range_finder.css new file mode 100644 index 000000000..ec5655a40 --- /dev/null +++ b/src/widget/invlerp_range_finder.css @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.neuroglancer-invlerp-range-finder-button-container { + display: flex; + justify-content: flex-start; + margin-top: 1px; +} + +.neuroglancer-invlerp-range-finder-button { + font-size: 11pt; + cursor: pointer; + margin-right: 2px; +} diff --git a/src/widget/invlerp_range_finder.ts b/src/widget/invlerp_range_finder.ts new file mode 100644 index 000000000..e3e445392 --- /dev/null +++ b/src/widget/invlerp_range_finder.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DisplayContext } from "#src/display_context.js"; +import { DataType } from "#src/util/data_type.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { computePercentilesFromEmpiricalHistogram } from "#src/util/empirical_cdf.js"; +import type { DataTypeInterval } from "#src/util/lerp.js"; +import { + dataTypeCompare, + dataTypeIntervalEqual, + defaultDataTypeRange, +} from "#src/util/lerp.js"; +import { NullarySignal } from "#src/util/signal.js"; +import type { HistogramSpecifications } from "#src/webgl/empirical_cdf.js"; +import { copyHistogramToCPU } from "#src/webgl/empirical_cdf.js"; +import "#src/widget/invlerp_range_finder.css"; + +const MAX_AUTO_RANGE_ITERATIONS = 16; +const FLOAT_EQUAL_TOLERANCE = 1e-3; + +interface AutoRangeData { + inputPercentileBounds: [number, number]; + autoComputeInProgress: boolean; + previouslyComputedRanges: DataTypeInterval[]; + numIterationsThisCompute: number; + invertedInitialRange: boolean; + finishedLerpRange: DataTypeInterval | null; +} + +interface ParentInvlerpWidget { + trackable: { + value: { + range?: DataTypeInterval; + window: DataTypeInterval; + }; + }; + dataType: DataType; + display: DisplayContext; + element: HTMLDivElement; + histogramSpecifications: HistogramSpecifications; + histogramIndex: number; +} + +export class AutoRangeFinder extends RefCounted { + autoRangeData: AutoRangeData = { + inputPercentileBounds: [0, 1], + autoComputeInProgress: false, + previouslyComputedRanges: [], + numIterationsThisCompute: 0, + invertedInitialRange: false, + finishedLerpRange: null, + }; + finished = new NullarySignal(); + element: HTMLDivElement; + + constructor(public parent: ParentInvlerpWidget) { + super(); + this.makeAutoRangeButtons( + () => this.autoComputeRange(0.0, 1.0), + () => this.autoComputeRange(0.01, 0.99), + () => this.autoComputeRange(0.05, 0.95), + ); + } + + get computedRange() { + return this.autoRangeData.finishedLerpRange; + } + + private wasInputInverted() { + const { range } = this.parent.trackable.value; + return range !== undefined && dataTypeCompare(range[0], range[1]) > 0; + } + + autoComputeRange(minPercentile: number, maxPercentile: number) { + if (!this.autoRangeData.autoComputeInProgress) { + const { autoRangeData } = this; + const { dataType, display } = this.parent; + + // Reset the auto-compute state + autoRangeData.inputPercentileBounds = [minPercentile, maxPercentile]; + this.resetAutoRangeData(autoRangeData); + autoRangeData.invertedInitialRange = this.wasInputInverted(); + display.force3DHistogramForAutoRange = true; + + // Create a large range to search over + // It's easier to contract the range than to expand it + const maxRange = + dataType === DataType.FLOAT32 + ? ([-65536, 65536] as [number, number]) + : defaultDataTypeRange[dataType]; + this.setTrackableValue(maxRange, maxRange); + display.scheduleRedraw(); + } + } + + setTrackableValue(range: DataTypeInterval, window: DataTypeInterval) { + const hasRange = this.parent.trackable.value.range !== undefined; + + const ensureWindowBoundsNotEqual = (window: DataTypeInterval) => { + if (dataTypeCompare(window[0], window[1]) === 0) { + return defaultDataTypeRange[this.parent.dataType]; + } + return window; + }; + + if (hasRange) { + this.parent.trackable.value = { + ...this.parent.trackable.value, + range, + window, + }; + } else { + this.parent.trackable.value = { + ...this.parent.trackable.value, + window: ensureWindowBoundsNotEqual(window), + }; + } + } + + public maybeAutoComputeRange() { + if (!this.autoRangeData.autoComputeInProgress) { + this.parent.display.force3DHistogramForAutoRange = false; + return; + } + const { autoRangeData } = this; + const { + trackable, + dataType, + display, + histogramSpecifications, + histogramIndex, + } = this.parent; + const gl = display.gl; + let { range } = trackable.value; + if (range === undefined) { + range = trackable.value.window; + } + + // Read the histogram from the GPU and compute new range based on this + const frameBuffer = + histogramSpecifications.getFramebuffers(gl)[histogramIndex]; + frameBuffer.bind(256, 1); + const empiricalCdf = copyHistogramToCPU(gl); + const { range: newRange, window: newWindow } = + computePercentilesFromEmpiricalHistogram( + empiricalCdf, + autoRangeData.inputPercentileBounds[0], + autoRangeData.inputPercentileBounds[1], + range, + dataType, + ); + + // If the range remains constant over two iterations + // or the range is a single value, + // or if we've exceeded the maximum number of iterations, stop + // For non-float32 data types we can exact match the range + let foundRange = false; + if (dataType !== DataType.FLOAT32) { + foundRange = autoRangeData.previouslyComputedRanges.some((prevRange) => + dataTypeIntervalEqual(dataType, prevRange, newRange), + ); + } else { + foundRange = autoRangeData.previouslyComputedRanges.some( + (prevRange) => + Math.abs((prevRange[0] as number) - (newRange[0] as number)) < + FLOAT_EQUAL_TOLERANCE && + Math.abs((prevRange[1] as number) - (newRange[1] as number)) < + FLOAT_EQUAL_TOLERANCE, + ); + } + const rangeBoundsEqual = dataTypeCompare(newRange[0], newRange[1]) === 0; + const exceededMaxIterations = + autoRangeData.numIterationsThisCompute > MAX_AUTO_RANGE_ITERATIONS; + autoRangeData.previouslyComputedRanges.push(newRange); + ++autoRangeData.numIterationsThisCompute; + if (foundRange || exceededMaxIterations || rangeBoundsEqual) { + if (autoRangeData.invertedInitialRange) { + newRange.reverse(); + } + this.resetAutoRangeData(autoRangeData, true /* finished */); + autoRangeData.finishedLerpRange = newRange; + this.setTrackableValue(newRange, newWindow); + this.finished.dispatch(); + } else { + display.force3DHistogramForAutoRange = true; + this.setTrackableValue(newRange, newRange); + } + } + private resetAutoRangeData( + autoRangeData: AutoRangeData, + finished: boolean = false, + ) { + autoRangeData.autoComputeInProgress = !finished; + autoRangeData.previouslyComputedRanges = []; + autoRangeData.numIterationsThisCompute = 0; + if (finished) { + autoRangeData.invertedInitialRange = false; + } + } + + makeAutoRangeButtons( + minMaxHandler: () => void, + oneTo99Handler: () => void, + fiveTo95Handler: () => void, + ) { + const parent = this.parent.element; + this.element = document.createElement("div"); + const buttonContainer = this.element; + buttonContainer.classList.add( + "neuroglancer-invlerp-range-finder-button-container", + ); + parent.appendChild(buttonContainer); + + const minMaxButton = document.createElement("button"); + minMaxButton.textContent = "Min-Max"; + minMaxButton.title = "Set range to the minimum and maximum values"; + minMaxButton.classList.add("neuroglancer-invlerp-range-finder-button"); + minMaxButton.addEventListener("click", minMaxHandler); + buttonContainer.appendChild(minMaxButton); + + const midButton = document.createElement("button"); + midButton.textContent = "1-99%"; + midButton.title = "Set range to the 1st and 99th percentiles"; + midButton.classList.add("neuroglancer-invlerp-range-finder-button"); + midButton.addEventListener("click", oneTo99Handler); + buttonContainer.appendChild(midButton); + + const highButton = document.createElement("button"); + highButton.textContent = "5-95%"; + highButton.title = "Set range to the 5th and 95th percentiles"; + highButton.classList.add("neuroglancer-invlerp-range-finder-button"); + highButton.addEventListener("click", fiveTo95Handler); + buttonContainer.appendChild(highButton); + } +} diff --git a/src/widget/transfer_function.browser_test.ts b/src/widget/transfer_function.browser_test.ts index f9a81233b..23b0d29b9 100644 --- a/src/widget/transfer_function.browser_test.ts +++ b/src/widget/transfer_function.browser_test.ts @@ -18,7 +18,11 @@ import { describe, it, expect } from "vitest"; import { TrackableValue } from "#src/trackable_value.js"; import { DataType } from "#src/util/data_type.js"; import { vec3, vec4 } from "#src/util/geom.js"; -import { defaultDataTypeRange } from "#src/util/lerp.js"; +import { + computeLerp, + dataTypeIntervalEqual, + defaultDataTypeRange, +} from "#src/util/lerp.js"; import { Uint64 } from "#src/util/uint64.js"; import { getShaderType } from "#src/webgl/shader_lib.js"; @@ -55,6 +59,90 @@ function makeTransferFunction(controlPoints: ControlPoint[]) { ); } +describe("Create default transfer function", () => { + for (const dataType of Object.values(DataType)) { + if (typeof dataType === "string") continue; + const transferFunction = new TransferFunction( + dataType, + new TrackableValue( + { + sortedControlPoints: new SortedControlPoints([], dataType), + window: defaultDataTypeRange[dataType], + defaultColor: vec3.fromValues(1, 0.2, 1), + channel: [], + }, + (x) => x, + ), + ); + it(`Creates two default transfer function points for ${DataType[dataType]} over the default window`, () => { + transferFunction.generateDefaultControlPoints(); + expect(transferFunction.sortedControlPoints.controlPoints.length).toBe(2); + const firstPoint = transferFunction.sortedControlPoints.controlPoints[0]; + const lastPoint = transferFunction.sortedControlPoints.controlPoints[1]; + const range = defaultDataTypeRange[dataType]; + const actualFirstPoint = computeLerp(range, dataType, 0.3); + const actualLastPoint = computeLerp(range, dataType, 0.7); + expect(firstPoint.inputValue).toStrictEqual(actualFirstPoint); + expect(lastPoint.inputValue).toStrictEqual(actualLastPoint); + expect(firstPoint.outputColor).toEqual(vec4.fromValues(0, 0, 0, 0)); + expect(lastPoint.outputColor).toEqual(vec4.fromValues(255, 51, 255, 255)); + }); + it(`Creates two default transfer function points for ${DataType[dataType]} over a custom window`, () => { + const window = + dataType === DataType.UINT64 + ? ([Uint64.ZERO, Uint64.fromNumber(100)] as [Uint64, Uint64]) + : ([0, 100] as [number, number]); + transferFunction.generateDefaultControlPoints(null, window); + expect(transferFunction.sortedControlPoints.controlPoints.length).toBe(2); + const firstPoint = transferFunction.sortedControlPoints.controlPoints[0]; + const lastPoint = transferFunction.sortedControlPoints.controlPoints[1]; + const actualFirstPoint = computeLerp(window, dataType, 0.3); + const actualLastPoint = computeLerp(window, dataType, 0.7); + expect(firstPoint.inputValue).toStrictEqual(actualFirstPoint); + expect(lastPoint.inputValue).toStrictEqual(actualLastPoint); + expect(firstPoint.outputColor).toEqual(vec4.fromValues(0, 0, 0, 0)); + expect(lastPoint.outputColor).toEqual(vec4.fromValues(255, 51, 255, 255)); + }); + it(`Creates two default transfer function points for ${DataType[dataType]} with a defined range`, () => { + const range = + dataType === DataType.UINT64 + ? ([Uint64.ZERO, Uint64.fromNumber(100)] as [Uint64, Uint64]) + : ([0, 100] as [number, number]); + transferFunction.generateDefaultControlPoints(range); + expect(transferFunction.sortedControlPoints.controlPoints.length).toBe(2); + const firstPoint = transferFunction.sortedControlPoints.controlPoints[0]; + const lastPoint = transferFunction.sortedControlPoints.controlPoints[1]; + expect(firstPoint.inputValue).toStrictEqual(range[0]); + expect(lastPoint.inputValue).toStrictEqual(range[1]); + expect(firstPoint.outputColor).toEqual(vec4.fromValues(0, 0, 0, 0)); + expect(lastPoint.outputColor).toEqual(vec4.fromValues(255, 51, 255, 255)); + }); + it(`Creates a window which bounds the control points for ${DataType[dataType]}`, () => { + const range = + dataType === DataType.UINT64 + ? ([Uint64.ZERO, Uint64.fromNumber(100)] as [Uint64, Uint64]) + : ([0, 100] as [number, number]); + const pointInputValues = [0, 20, 40, 60, 80, 100]; + transferFunction.sortedControlPoints.clear(); + for (const inputValue of pointInputValues) { + const valueToAdd = + dataType === DataType.UINT64 + ? Uint64.fromNumber(inputValue) + : inputValue; + transferFunction.addPoint( + new ControlPoint(valueToAdd, vec4.fromValues(0, 0, 0, 0)), + ); + } + transferFunction.generateDefaultWindow(); + const window = transferFunction.trackable.value.window; + expect( + dataTypeIntervalEqual(dataType, window, range), + `Got ${window} expected ${range}`, + ).toBeTruthy(); + }); + } +}); + describe("lerpBetweenControlPoints", () => { const output = new Uint8Array( NUM_COLOR_CHANNELS * FIXED_TRANSFER_FUNCTION_LENGTH, diff --git a/src/widget/transfer_function.css b/src/widget/transfer_function.css index d402128d3..b5e9be2aa 100644 --- a/src/widget/transfer_function.css +++ b/src/widget/transfer_function.css @@ -20,10 +20,22 @@ margin-top: 5px; } -.neuroglancer-transfer-function-color-picker { +.neuroglancer-transfer-function-color-picker-container { text-align: right; } +.neuroglancer-transfer-function-color-picker { + height: 24px; + display: inline-block; +} + +.neuroglancer-transfer-function-clear-button { + font-size: 11pt; + cursor: pointer; + margin-right: 2px; + margin-left: auto; +} + .neuroglancer-transfer-function-widget-bound { background-color: transparent; border-color: transparent; diff --git a/src/widget/transfer_function.ts b/src/widget/transfer_function.ts index 7d60548a2..cfb53e3bd 100644 --- a/src/widget/transfer_function.ts +++ b/src/widget/transfer_function.ts @@ -31,6 +31,7 @@ import { } from "#src/util/array.js"; import { DATA_TYPE_SIGNED, DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; +import { expandRange } from "#src/util/empirical_cdf.js"; import { EventActionMap, registerActionListener, @@ -40,13 +41,14 @@ import type { DataTypeInterval } from "#src/util/lerp.js"; import { computeInvlerp, computeLerp, + dataTypeIntervalEqual, defaultDataTypeRange, getIntervalBoundsEffectiveFraction, parseDataTypeValue, } from "#src/util/lerp.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; -import type { Uint64 } from "#src/util/uint64.js"; +import { Uint64 } from "#src/util/uint64.js"; import { getWheelZoomAmount } from "#src/util/wheel_zoom.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import type { Buffer } from "#src/webgl/buffer.js"; @@ -77,6 +79,7 @@ import { createCDFLineShader, NUM_CDF_LINES, } from "#src/widget/invlerp.js"; +import { AutoRangeFinder } from "#src/widget/invlerp_range_finder.js"; import type { LayerControlFactory, LayerControlTool, @@ -203,6 +206,17 @@ export class SortedControlPoints { get length() { return this.controlPoints.length; } + setDefaultControlPoints( + controlPointRange: DataTypeInterval, + finalColor: vec4, + ) { + this.clear(); + this.addPoint(new ControlPoint(controlPointRange[0], kZeroVec4)); + this.addPoint(new ControlPoint(controlPointRange[1], finalColor)); + } + clear() { + this.controlPoints = []; + } addPoint(controlPoint: ControlPoint) { const { inputValue, outputColor } = controlPoint; const exactMatch = this.controlPoints.findIndex( @@ -399,6 +413,7 @@ export class LookupTable { */ export class TransferFunction extends RefCounted { lookupTable: LookupTable; + autoPointUpdateEnabled: boolean = true; constructor( public dataType: DataType, public trackable: WatchableValueInterface, @@ -423,6 +438,55 @@ export class TransferFunction extends RefCounted { addPoint(controlPoint: ControlPoint) { this.sortedControlPoints.addPoint(controlPoint); } + /** + * Generate two default control points for the transfer function, from transparent to a full color. + * @param range The range of the two control points to interpolate between. + * If null, the data window will be used to generate the control points. + * @param window The window of the data in view. Control points are placed + * at 30% and 70% of the window if range is not provided. + * If the window is not provided, the current window of the transfer function + * will be used. + */ + generateDefaultControlPoints( + range: DataTypeInterval | null = null, + window: DataTypeInterval | undefined = undefined, + ) { + if (!this.autoPointUpdateEnabled) { + return; + } + window = window !== undefined ? window : this.trackable.value.window; + const controlPointRange = + range !== null + ? range + : ([ + computeLerp(window, this.dataType, 0.3), + computeLerp(window, this.dataType, 0.7), + ] as DataTypeInterval); + // If the range ends up being equal, instead just use the window + if (controlPointRange[0] === controlPointRange[1]) { + controlPointRange[0] = window[0]; + controlPointRange[1] = window[1]; + } + const color = this.trackable.value.defaultColor; + const colorOpacity = vec4.fromValues( + Math.round(color[0] * 255), + Math.round(color[1] * 255), + Math.round(color[2] * 255), + 255, + ); + this.sortedControlPoints.setDefaultControlPoints( + controlPointRange, + colorOpacity, + ); + } + generateDefaultWindow(range: DataTypeInterval | null = null) { + const actualRange = range !== null ? range : this.sortedControlPoints.range; + const window = expandRange(actualRange, this.dataType); + this.trackable.value = { + ...this.trackable.value, + window, + }; + } updatePoint(index: number, controlPoint: ControlPoint): number { return this.sortedControlPoints.updatePoint(index, controlPoint); } @@ -1195,65 +1259,6 @@ out_color = tempColor * alpha; } } -/** - * Create the bounds on the UI window inputs for the transfer function widget - */ -function createWindowBoundInputs( - dataType: DataType, - model: WatchableValueInterface, -) { - function createWindowBoundInput(endpoint: number): HTMLInputElement { - const e = document.createElement("input"); - e.addEventListener("focus", () => { - e.select(); - }); - e.classList.add("neuroglancer-transfer-function-widget-bound"); - e.type = "text"; - e.spellcheck = false; - e.autocomplete = "off"; - e.title = `${ - endpoint === 0 ? "Lower" : "Upper" - } window for transfer function`; - return e; - } - - const container = document.createElement("div"); - container.classList.add("neuroglancer-transfer-function-window-bounds"); - const inputs = [createWindowBoundInput(0), createWindowBoundInput(1)]; - for (let endpointIndex = 0; endpointIndex < 2; ++endpointIndex) { - const input = inputs[endpointIndex]; - input.addEventListener("input", () => { - updateInputBoundWidth(input); - }); - input.addEventListener("change", () => { - const existingBounds = model.value.window; - const intervals = { range: existingBounds, window: existingBounds }; - try { - const value = parseDataTypeValue(dataType, input.value); - const window = getUpdatedRangeAndWindowParameters( - intervals, - "window", - endpointIndex, - value, - /*fitRangeInWindow=*/ true, - ).window; - if (window[0] === window[1]) { - throw new Error("Window bounds cannot be equal"); - } - model.value = { ...model.value, window }; - } catch { - updateInputBoundValue(input, existingBounds[endpointIndex]); - } - }); - } - container.appendChild(inputs[0]); - container.appendChild(inputs[1]); - return { - container, - inputs, - }; -} - const inputEventMap = EventActionMap.fromObject({ "shift?+mousedown0": { action: "add-or-drag-point" }, "shift+dblclick0": { action: "remove-point" }, @@ -1295,6 +1300,7 @@ class TransferFunctionController extends RefCounted { const nearestIndex = this.findControlPointIfNearCursor(mouseEvent); if (nearestIndex !== -1) { this.transferFunction.removePoint(nearestIndex); + this.disableAutoPointUpdate(); this.updateValue({ ...this.getModel(), sortedControlPoints: @@ -1317,6 +1323,7 @@ class TransferFunctionController extends RefCounted { nearestIndex, colorInAbsoluteValue, ); + this.disableAutoPointUpdate(); this.updateValue({ ...this.getModel(), sortedControlPoints: @@ -1345,14 +1352,20 @@ class TransferFunctionController extends RefCounted { (1 - relativeX) * zoomAmount + relativeX, ); if (newLower !== newUpper) { - this.setModel({ + const newWindow = [newLower, newUpper] as DataTypeInterval; + this.transferFunction.generateDefaultControlPoints(null, newWindow); + this.updateValue({ ...model, - window: [newLower, newUpper] as DataTypeInterval, + sortedControlPoints: this.transferFunction.sortedControlPoints, + window: newWindow, }); } }, ); } + disableAutoPointUpdate() { + this.transferFunction.autoPointUpdateEnabled = false; + } /** * Get fraction of distance in x along bounding rect for a MouseEvent. */ @@ -1405,6 +1418,7 @@ class TransferFunctionController extends RefCounted { color[2], normalizedY, ); + this.disableAutoPointUpdate(); this.transferFunction.addPoint( new ControlPoint( this.convertPanelSpaceInputToAbsoluteValue(normalizedX), @@ -1420,21 +1434,33 @@ class TransferFunctionController extends RefCounted { }; } moveControlPoint(event: MouseEvent): TransferFunctionParameters | undefined { - if (this.currentGrabbedControlPointIndex !== -1) { + const { controlPoints } = this.transferFunction.sortedControlPoints; + if ( + this.currentGrabbedControlPointIndex !== -1 && + this.currentGrabbedControlPointIndex < controlPoints.length + ) { const position = this.getControlPointPosition(event); if (position === undefined) return undefined; const { normalizedX, normalizedY } = position; - const newColor = - this.transferFunction.trackable.value.sortedControlPoints.controlPoints[ - this.currentGrabbedControlPointIndex - ].outputColor; + const selectedPoint = controlPoints[this.currentGrabbedControlPointIndex]; + let newInputValue = + this.convertPanelSpaceInputToAbsoluteValue(normalizedX); + // If the input value is the same as another control point keep the input value the same, don't use the new input value + for (let i = 0; i < controlPoints.length; i++) { + if ( + controlPoints[i].inputValue === newInputValue && + i !== this.currentGrabbedControlPointIndex + ) { + newInputValue = selectedPoint.inputValue; + } + } + + const newColor = vec4.clone(selectedPoint.outputColor); newColor[3] = Math.round(normalizedY * 255); + this.disableAutoPointUpdate(); this.currentGrabbedControlPointIndex = this.transferFunction.updatePoint( this.currentGrabbedControlPointIndex, - new ControlPoint( - this.convertPanelSpaceInputToAbsoluteValue(normalizedX), - newColor, - ), + new ControlPoint(newInputValue, newColor), ); return { ...this.getModel(), @@ -1476,10 +1502,11 @@ class TransferFunctionController extends RefCounted { * distance in the Y direction is returned. */ findControlPointIfNearCursor(event: MouseEvent) { - const { transferFunction } = this; + const { transferFunction, dataType } = this; const { window } = transferFunction.trackable.value; const numControlPoints = transferFunction.sortedControlPoints.controlPoints.length; + function convertControlPointInputToPanelSpace(controlPointIndex: number) { if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { return null; @@ -1490,6 +1517,7 @@ class TransferFunctionController extends RefCounted { .inputValue, ); } + function convertControlPointOpacityToPanelSpace(controlPointIndex: number) { if (controlPointIndex < 0 || controlPointIndex >= numControlPoints) { return null; @@ -1499,6 +1527,53 @@ class TransferFunctionController extends RefCounted { .outputColor[3] / 255 ); } + + function calculateControlPointGrabDistance() { + let windowSize = 0.0; + if (dataType == DataType.UINT64) { + const tempUint = new Uint64(); + windowSize = Uint64.subtract( + tempUint, + window[1] as Uint64, + window[0] as Uint64, + ).toNumber(); + } else if (dataType == DataType.FLOAT32) { + // Floating point data can have very small windows with many points + windowSize = 1.0 / CONTROL_POINT_X_GRAB_DISTANCE; + } else { + windowSize = (window[1] as number) - (window[0] as number); + } + const controlPointGrabDistance = Math.max( + CONTROL_POINT_X_GRAB_DISTANCE, + 1.0 / windowSize, + ); + return controlPointGrabDistance; + } + + function findBestMatchingControlPoint(controlPointGrabDistance: number) { + const possibleMatches: [number, number][] = []; + const totalControlPoints = transferFunction.sortedControlPoints.length; + for (let i = 0; i < totalControlPoints; i++) { + const currentPosition = convertControlPointInputToPanelSpace(i); + const currentDistance = + currentPosition !== null + ? Math.abs(currentPosition - mouseXPosition) + : Infinity; + if (currentDistance <= controlPointGrabDistance) { + const currentOpacity = convertControlPointOpacityToPanelSpace(i); + possibleMatches.push([ + i, + Math.abs(currentOpacity! - mouseYPosition) + 10 * currentDistance, + ]); + } + } + const bestMatch = + possibleMatches.length === 0 + ? nearestControlPointIndex + : possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; + return bestMatch; + } + const position = this.getControlPointPosition(event); if (position === undefined) return -1; const mouseXPosition = position.normalizedX; @@ -1510,59 +1585,15 @@ class TransferFunctionController extends RefCounted { } const nearestControlPointPanelPosition = convertControlPointInputToPanelSpace(nearestControlPointIndex)!; + const controlPointGrabDistance = calculateControlPointGrabDistance(); if ( Math.abs(mouseXPosition - nearestControlPointPanelPosition) > - CONTROL_POINT_X_GRAB_DISTANCE + controlPointGrabDistance ) { return -1; } - // If points are nearby in X space, use Y space to break ties - const possibleMatches: [number, number][] = [ - [ - nearestControlPointIndex, - Math.abs( - convertControlPointOpacityToPanelSpace(nearestControlPointIndex)! - - mouseYPosition, - ), - ], - ]; - const nextPosition = convertControlPointInputToPanelSpace( - nearestControlPointIndex + 1, - ); - const nextDistance = - nextPosition !== null - ? Math.abs(nextPosition - mouseXPosition) - : Infinity; - if (nextDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleMatches.push([ - nearestControlPointIndex + 1, - Math.abs( - convertControlPointOpacityToPanelSpace( - nearestControlPointIndex + 1, - )! - mouseYPosition, - ), - ]); - } - - const previousPosition = convertControlPointInputToPanelSpace( - nearestControlPointIndex - 1, - ); - const previousDistance = - previousPosition !== null - ? Math.abs(previousPosition - mouseXPosition) - : Infinity; - if (previousDistance <= CONTROL_POINT_X_GRAB_DISTANCE) { - possibleMatches.push([ - nearestControlPointIndex - 1, - Math.abs( - convertControlPointOpacityToPanelSpace( - nearestControlPointIndex - 1, - )! - mouseYPosition, - ), - ]); - } - const bestMatch = possibleMatches.sort((a, b) => a[1] - b[1])[0][0]; - return bestMatch; + // If points are nearby in X space, use mouse distance in both X and Y to determine the best match + return findBestMatchingControlPoint(controlPointGrabDistance); } } @@ -1573,8 +1604,16 @@ class TransferFunctionWidget extends Tab { private transferFunctionPanel = this.registerDisposer( new TransferFunctionPanel(this), ); + autoRangeFinder: AutoRangeFinder; + window = this.createWindowBoundInputs(); - window = createWindowBoundInputs(this.dataType, this.trackable); + get autoPointUpdateEnabled() { + return this.transferFunctionPanel.transferFunction.autoPointUpdateEnabled; + } + + set autoPointUpdateEnabled(value: boolean) { + this.transferFunctionPanel.transferFunction.autoPointUpdateEnabled = value; + } get texture() { return this.histogramSpecifications.getFramebuffers(this.display.gl)[ @@ -1601,9 +1640,65 @@ class TransferFunctionWidget extends Tab { element.appendChild(this.window.container); this.window.container.dispatchEvent(new Event("change")); - // Color picker element + // Container that sits at the bottom of the transfer function widget + this.autoRangeFinder = this.registerDisposer(new AutoRangeFinder(this)); + this.autoRangeFinder.element.appendChild(this.createClearButton()); + this.autoRangeFinder.element.appendChild(this.createColorPicker(trackable)); + + // If no points and no window, set the default control points for the transfer function + const currentWindow = this.trackable.value.window; + const defaultWindow = defaultDataTypeRange[this.dataType]; + const windowUnset = dataTypeIntervalEqual( + this.dataType, + currentWindow, + defaultWindow, + ); + if (this.trackable.value.sortedControlPoints.length === 0 && windowUnset) { + this.autoRangeFinder.autoComputeRange(0, 1); + } + // Otherwise, mark that points should not auto update unless points are cleared + else { + this.autoPointUpdateEnabled = false; + if (this.trackable.value.sortedControlPoints.length > 0 && windowUnset) { + this.transferFunctionPanel.transferFunction.generateDefaultWindow(); + } + } + + this.updateControlPointsAndDraw(); + this.registerDisposer( + this.trackable.changed.add(() => { + this.updateControlPointsAndDraw(); + }), + ); + this.registerDisposer( + this.display.updateFinished.add(() => { + this.autoRangeFinder.maybeAutoComputeRange(); + }), + ); + this.registerDisposer( + this.autoRangeFinder.finished.add(() => { + this.generateDefaultControlPoints(this.autoRangeFinder.computedRange); + }), + ); + } + private createClearButton() { + const clearButton = document.createElement("button"); + clearButton.classList.add("neuroglancer-transfer-function-clear-button"); + clearButton.textContent = "Clear"; + clearButton.title = "Clear all control points"; + clearButton.addEventListener("click", () => { + this.clearPoints(); + }); + return clearButton; + } + + private createColorPicker( + trackable: WatchableValueInterface, + ) { const colorPickerDiv = document.createElement("div"); - colorPickerDiv.classList.add("neuroglancer-transfer-function-color-picker"); + colorPickerDiv.classList.add( + "neuroglancer-transfer-function-color-picker-container", + ); const colorPicker = this.registerDisposer( new ColorWidget( makeCachedDerivedWatchableValue( @@ -1613,8 +1708,10 @@ class TransferFunctionWidget extends Tab { () => vec3.fromValues(1, 1, 1), ), ); - colorPicker.element.title = "Transfer Function Color Picker"; - colorPicker.element.id = "neuroglancer-tf-color-widget"; + colorPicker.element.classList.add( + "neuroglancer-transfer-function-color-picker", + ); + colorPicker.element.title = "Transfer function color picker"; colorPicker.element.addEventListener("change", () => { trackable.value = { ...this.trackable.value, @@ -1628,15 +1725,68 @@ class TransferFunctionWidget extends Tab { }; }); colorPickerDiv.appendChild(colorPicker.element); + return colorPickerDiv; + } - element.appendChild(colorPickerDiv); - this.updateControlPointsAndDraw(); - this.registerDisposer( - this.trackable.changed.add(() => { - this.updateControlPointsAndDraw(); - }), - ); + private createWindowBoundInputs() { + const dataType = this.dataType; + const model = this.trackable; + function createWindowBoundInput(endpoint: number): HTMLInputElement { + const e = document.createElement("input"); + e.addEventListener("focus", () => { + e.select(); + }); + e.classList.add("neuroglancer-transfer-function-widget-bound"); + e.type = "text"; + e.spellcheck = false; + e.autocomplete = "off"; + e.title = `${ + endpoint === 0 ? "Lower" : "Upper" + } window for transfer function`; + return e; + } + + const container = document.createElement("div"); + container.classList.add("neuroglancer-transfer-function-window-bounds"); + const inputs = [createWindowBoundInput(0), createWindowBoundInput(1)]; + for (let endpointIndex = 0; endpointIndex < 2; ++endpointIndex) { + const input = inputs[endpointIndex]; + input.addEventListener("input", () => { + updateInputBoundWidth(input); + }); + input.addEventListener("change", () => { + const existingBounds = model.value.window; + const intervals = { range: existingBounds, window: existingBounds }; + try { + const value = parseDataTypeValue(dataType, input.value); + const window = getUpdatedRangeAndWindowParameters( + intervals, + "window", + endpointIndex, + value, + /*fitRangeInWindow=*/ true, + ).window; + if (window[0] === window[1]) { + throw new Error("Window bounds cannot be equal"); + } + this.transferFunctionPanel.transferFunction.generateDefaultControlPoints( + null, + window, + ); + model.value = { ...model.value, window }; + } catch { + updateInputBoundValue(input, existingBounds[endpointIndex]); + } + }); + } + container.appendChild(inputs[0]); + container.appendChild(inputs[1]); + return { + container, + inputs, + }; } + updateView() { for (let i = 0; i < 2; ++i) { updateInputBoundValue( @@ -1650,6 +1800,27 @@ class TransferFunctionWidget extends Tab { this.transferFunctionPanel.update(); this.updateView(); } + updateTrackable(value: TransferFunctionParameters) { + this.trackable.value = value; + } + generateDefaultControlPoints(range: DataTypeInterval | null = null) { + const transferFunction = this.transferFunctionPanel.transferFunction; + transferFunction.generateDefaultControlPoints(range); + this.updateTrackable({ + ...this.trackable.value, + sortedControlPoints: transferFunction.sortedControlPoints, + }); + } + clearPoints() { + const sortedControlPoints = + this.transferFunctionPanel.transferFunction.sortedControlPoints; + sortedControlPoints.clear(); + this.updateTrackable({ + ...this.trackable.value, + sortedControlPoints, + }); + this.autoPointUpdateEnabled = true; + } } /** @@ -1788,7 +1959,7 @@ export function transferFunctionLayerControl( const position = context.registerDisposer( new Position(channelCoordinateSpaceCombiner.combined), ); - const positiionWidget = context.registerDisposer( + const positionWidget = context.registerDisposer( new PositionWidget(position, channelCoordinateSpaceCombiner, { copyButton: false, }), @@ -1816,7 +1987,7 @@ export function transferFunctionLayerControl( }; updatePosition(); context.registerDisposer(watchableValue.changed.add(updatePosition)); - options.labelContainer.appendChild(positiionWidget.element); + options.labelContainer.appendChild(positionWidget.element); } const control = context.registerDisposer( new TransferFunctionWidget( diff --git a/src/widget/virtual_list.ts b/src/widget/virtual_list.ts index b83d3d88c..7410d33fa 100644 --- a/src/widget/virtual_list.ts +++ b/src/widget/virtual_list.ts @@ -300,7 +300,7 @@ export class VirtualList extends RefCounted { private debouncedUpdateView = this.registerCancellable( animationFrameDebounce(() => this.updateView()), ); - private resizeObserver = new ResizeObserver(() => this.updateView()); + private resizeObserver = new ResizeObserver(() => this.debouncedUpdateView()); constructor(options: { source: VirtualListSource;