diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c6d1ad62..bdb9662fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,19 +3,21 @@ name: Build on: push: branches: - - master + - spelunker tags: - v** pull_request: jobs: - client: + build-and-deploy: + permissions: + contents: "read" + id-token: "write" + deployments: "write" strategy: matrix: os: - "ubuntu-latest" - - "windows-latest" - - "macos-latest" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -29,273 +31,56 @@ jobs: cache-dependency-path: | package-lock.json examples/**/package-lock.json + fetch-depth: 0 - run: npm install - - run: npm run format:fix - - name: Check for dirty working directory - run: git diff --exit-code - - run: npm run lint:check - name: Typecheck with TypeScript run: npm run typecheck - - name: Build client bundles - run: | - build_info="{'tag':'$(git describe --always --tags)', 'url':'https://github.com/google/neuroglancer/commit/$(git rev-parse HEAD)', 'timestamp':'$(date)'}" - npm run build -- --no-typecheck --no-lint --define NEUROGLANCER_BUILD_INFO="${build_info}" - echo $build_info > ./dist/client/version.json + - name: Get branch name (merge) + if: github.event_name != 'pull_request' shell: bash - - name: Build Python client bundles - run: npm run build-python -- --no-typecheck --no-lint - - run: npm run build-package - - run: npm publish --dry-run - working-directory: dist/package - - uses: ./.github/actions/setup-firefox - - name: Run JavaScript tests (including WebGL) - run: npm test - if: ${{ runner.os != 'macOS' }} - - name: Run JavaScript tests (excluding WebGL) - run: npm test -- --project node - if: ${{ runner.os == 'macOS' }} - - name: Run JavaScript benchmarks - run: npm run benchmark - - name: Upload NPM package as artifact - uses: actions/upload-artifact@v4 - with: - name: npm-package - path: dist/package - if: ${{ runner.os == 'Linux' }} - - name: Upload client as artifact - uses: actions/upload-artifact@v4 - with: - name: client - path: dist/client - if: ${{ runner.os == 'Linux' }} - - # Builds Python package and runs Python tests - # - # On ubuntu-latest, this also runs browser-based tests. On Mac OS and - # Windows, this only runs tests that do not require a browser, since a working - # headless WebGL2 implementation is not available on Github actions. - python-tests: - strategy: - matrix: - python-version: - - "3.9" - - "3.12" - os: - - "ubuntu-latest" - - "windows-latest" - - "macos-latest" - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - # Need full history to determine version number. - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20.x - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - # Uncomment the action below for an interactive shell - # - name: Setup tmate session - # uses: mxschmitt/action-tmate@v3 - - name: Install Python packaging/test tools - 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 - run: git diff --exit-code - - name: Test with tox - run: tox -e ${{ fromJSON('["skip-browser-tests","firefox-xvfb"]')[runner.os == 'Linux'] }} - # Verify that editable install works - - name: Install in editable form - run: pip install -e . --config-settings editable_mode=strict - - name: Run Python tests against editable install (excluding WebGL) - working-directory: python/tests - run: pytest -vv --skip-browser-tests - - python-build-package: - strategy: - matrix: - include: - - os: "ubuntu-latest" - cibw_build: "*" - wheel_identifier: "linux" - - os: "windows-latest" - cibw_build: "*" - wheel_identifier: "windows" - - os: "macos-14" - cibw_build: "*_x86_64" - wheel_identifier: "macos_x86_64" - - os: "macos-14" - cibw_build: "*_arm64" - wheel_identifier: "macos_arm64" - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - # Need full history to determine version number. - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: "npm" - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.x - - name: Get pip cache dir - id: pip-cache - run: | - echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT" - - run: npm install - - run: | - build_info="{'tag':'$(git describe --always --tags)', 'url':'https://github.com/google/neuroglancer/commit/$(git rev-parse HEAD)', 'timestamp':'$(date)'}" - npm run build-python -- --no-typecheck --no-lint --define NEUROGLANCER_BUILD_INFO="${build_info}" + run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV + - name: Get branch name (pull request) + if: github.event_name == 'pull_request' + shell: bash + run: echo "BRANCH_NAME=$(echo ${GITHUB_HEAD_REF} | tr / -)" >> $GITHUB_ENV + - run: echo "BRANCH_NAME_URL=$(echo ${{ env.BRANCH_NAME }} | tr / - | tr _ -)" >> $GITHUB_ENV + - name: Get build info + run: echo "BUILD_INFO={\"tag\":\"$(git describe --always --tags)\", \"url\":\"https://github.com/${{github.repository}}/commit/$(git rev-parse HEAD)\", \"timestamp\":\"$(date)\", \"branch\":\"${{github.repository}}/${{env.BRANCH_NAME}}\"}" >> $GITHUB_ENV shell: bash - name: Check for dirty working directory run: git diff --exit-code - - name: Install setuptools - run: pip install setuptools - - name: Build Python source distribution (sdist) - run: python setup.py sdist --format gztar - if: ${{ runner.os == 'Linux' }} - - name: Install cibuildwheel - run: pip install cibuildwheel - - name: Build Python wheels - run: bash -xve ./python/build_tools/cibuildwheel.sh - env: - # On Linux, share pip cache with manylinux docker containers - CIBW_ENVIRONMENT_LINUX: PIP_CACHE_DIR=/host${{ steps.pip-cache.outputs.dir }} - CIBW_BEFORE_ALL_LINUX: /project/python/build_tools/cibuildwheel_linux_cache_setup.sh /host${{ steps.pip-cache.outputs.dir }} - CIBW_BUILD: ${{ matrix.cibw_build }} - - name: Upload wheels as artifacts - uses: actions/upload-artifact@v4 - with: - name: python-wheels-${{ matrix.wheel_identifier }} - path: | - dist/*.whl - dist/*.tar.gz - - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - name: Setup Graphviz - uses: ts-graphviz/setup-graphviz@b1de5da23ed0a6d14e0aeee8ed52fdd87af2363c # v2.0.2 - with: - macos-skip-brew-update: "true" - - name: Install nox - run: pip install nox - - name: Build docs - run: nox -s docs - - name: Upload docs as artifact - uses: actions/upload-artifact@v4 - with: - name: docs - path: | - dist/docs - - publish-package: - # Only publish package on push to tag or default branch. - if: ${{ github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') }} - runs-on: ubuntu-latest - needs: - - "client" - - "python-build-package" - - "docs" - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 20.x - registry-url: "https://registry.npmjs.org" - - uses: actions/download-artifact@v4 - with: - pattern: python-wheels-* - path: dist - merge-multiple: true - - uses: actions/download-artifact@v4 - with: - name: npm-package - path: npm-package - # - name: Publish to PyPI (test server) - # uses: pypa/gh-action-pypi-publish@54b39fb9371c0b3a6f9f14bb8a67394defc7a806 # 2020-09-25 - # with: - # user: __token__ - # password: ${{ secrets.pypi_test_token }} - - name: Publish to PyPI (main server) - uses: pypa/gh-action-pypi-publish@54b39fb9371c0b3a6f9f14bb8a67394defc7a806 # 2020-09-25 - with: - user: __token__ - password: ${{ secrets.pypi_token }} - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - - name: Publish to NPM registry - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - run: npm publish - working-directory: npm-package - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # Download dist/client after publishing to PyPI, because PyPI publish - # action fails if dist/client directory is present. - - uses: actions/download-artifact@v4 - with: - name: client - path: dist/client - - name: Publish client to Firebase hosting - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - firebaseServiceAccount: "${{ secrets.FIREBASE_HOSTING_SERVICE_ACCOUNT_KEY }}" - projectId: neuroglancer-demo - channelId: live - target: app - # Download dist/docs after publishing to PyPI, because PyPI publish - # action fails if dist/docs directory is present. - - uses: actions/download-artifact@v4 - with: - name: docs - path: dist/docs - - name: Publish docs to Firebase hosting - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - firebaseServiceAccount: "${{ secrets.FIREBASE_HOSTING_SERVICE_ACCOUNT_KEY }}" - projectId: neuroglancer-demo - channelId: live - target: docs - - ngauth: - strategy: - matrix: - os: - - ubuntu-latest - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - name: Setup go - uses: actions/setup-go@v5 - with: - go-version-file: ngauth_server/go.mod - cache-dependency-path: ngauth_server/go.sum - - run: go build . - working-directory: ngauth_server - wasm: - # Ensures that .wasm files are reproducible. - strategy: - matrix: - os: - - ubuntu-latest - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - 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 + - name: Build client bundles + run: npm run build -- --no-typecheck --no-lint --define STATE_SERVERS=$(cat config/state_servers.json | tr -d " \t\n\r") --define NEUROGLANCER_BUILD_INFO='${{ env.BUILD_INFO }}' --define CUSTOM_BINDINGS=$(cat config/custom-keybinds.json | tr -d " \t\n\r") + - name: Write build info + run: echo $BUILD_INFO > ./dist/client/version.json + shell: bash + - run: cp -r ./dist/client appengine/frontend/static/ + - name: start deployment + uses: bobheadxi/deployments@v1 + id: deployment + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ env.BRANCH_NAME }} + desc: Setting up staging deployment for ${{ env.BRANCH_NAME }} + - id: "auth" + uses: "google-github-actions/auth@v1" + with: + workload_identity_provider: "projects/483670036293/locations/global/workloadIdentityPools/neuroglancer-github/providers/github" + service_account: "chris-apps-deploy@seung-lab.iam.gserviceaccount.com" + - id: deploy + uses: google-github-actions/deploy-appengine@main + with: + version: ${{ env.GITHUB_SHA }} + deliverables: appengine/frontend/app.yaml + promote: true + - name: update deployment status + uses: bobheadxi/deployments@v1 + if: always() + with: + step: finish + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ steps.deployment.outputs.env }} + env_url: ${{ steps.deploy.outputs.url }} + status: ${{ job.status }} + deployment_id: ${{ steps.deployment.outputs.deployment_id }} diff --git a/.github/workflows/build_preview.yml b/.github/workflows/build_preview.yml deleted file mode 100644 index 9dcac7d1e..000000000 --- a/.github/workflows/build_preview.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build preview - -on: - pull_request: - -jobs: - upload: - strategy: - matrix: - node-version: - - "20.x" - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: | - build_info="{'tag':'$(git describe --always --tags)', 'url':'https://github.com/google/neuroglancer/commit/$(git rev-parse HEAD)', 'timestamp':'$(date)'}" - npm run build -- --no-typecheck --no-lint --define NEUROGLANCER_BUILD_INFO="${build_info}" - - name: Upload client as artifact - uses: actions/upload-artifact@v4 - with: - name: client - path: | - dist/client/* diff --git a/appengine/frontend/.gcloudignore b/appengine/frontend/.gcloudignore new file mode 100644 index 000000000..c2edb00e9 --- /dev/null +++ b/appengine/frontend/.gcloudignore @@ -0,0 +1,5 @@ +.gcloudignore +.envrc +.git +.gitignore +node_modules/ \ No newline at end of file diff --git a/appengine/frontend/app.yaml b/appengine/frontend/app.yaml new file mode 100644 index 000000000..9d82ed937 --- /dev/null +++ b/appengine/frontend/app.yaml @@ -0,0 +1,19 @@ +runtime: python312 + +service: spelunker + +handlers: + # Handle the main page by serving the index page. + # Note the $ to specify the end of the path, since app.yaml does prefix matching. + - url: /$ + static_files: static/index.html + upload: static/index.html + login: optional + secure: always + redirect_http_response_code: 301 + + - url: / + static_dir: static + login: optional + secure: always + redirect_http_response_code: 301 diff --git a/config/custom-keybinds.json b/config/custom-keybinds.json new file mode 100644 index 000000000..7f7790f67 --- /dev/null +++ b/config/custom-keybinds.json @@ -0,0 +1,21 @@ +{ + "keym": { + "layer": "segmentation", + "tool": "grapheneMergeSegments", + "provider": "graphene" + }, + "keyc": { + "layer": "segmentation", + "tool": "grapheneMulticutSegments", + "provider": "graphene" + }, + "keyf": { + "layer": "segmentation", + "tool": "grapheneFindPath", + "provider": "graphene" + }, + "keyx": false, + "control+shift+keyx": "clear-segments", + "bracketleft": "select-previous", + "bracketright": "select-next" +} diff --git a/config/state_servers.json b/config/state_servers.json new file mode 100644 index 000000000..c61efbd86 --- /dev/null +++ b/config/state_servers.json @@ -0,0 +1,6 @@ +{ + "cave": { + "url": "middleauth+https://global.daf-apis.com/nglstate/api/v1/post", + "default": true + } +} diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index c9da60d6b..1fa7ab879 100644 --- a/src/annotation/annotation_layer_state.ts +++ b/src/annotation/annotation_layer_state.ts @@ -132,7 +132,7 @@ void main() { export class AnnotationDisplayState extends RefCounted { annotationProperties = new WatchableValue< - AnnotationPropertySpec[] | undefined + readonly Readonly[] | undefined >(undefined); shader = makeTrackableFragmentMain(DEFAULT_FRAGMENT_MAIN); shaderControls = new ShaderControlState( @@ -159,6 +159,7 @@ export class AnnotationDisplayState extends RefCounted { new WatchableAnnotationRelationshipStates(), ); ignoreNullSegmentFilter = new TrackableBoolean(true); + swapVisibleSegmentsOnMove = new TrackableBoolean(true); disablePicking = new WatchableValue(false); displayUnfiltered = makeCachedLazyDerivedWatchableValue( (map, ignoreNullSegmentFilter) => { diff --git a/src/annotation/frontend_source.ts b/src/annotation/frontend_source.ts index 1d6bc9423..f0c84629b 100644 --- a/src/annotation/frontend_source.ts +++ b/src/annotation/frontend_source.ts @@ -56,6 +56,7 @@ import { SliceViewChunkSource, } from "#src/sliceview/frontend.js"; import { StatusMessage } from "#src/status.js"; +import { WatchableValue } from "#src/trackable_value.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { ENDIANNESS, Endianness } from "#src/util/endian.js"; import * as matrix from "#src/util/matrix.js"; @@ -517,7 +518,9 @@ export class MultiscaleAnnotationSource spatiallyIndexedSources = new Set>(); rank: number; readonly relationships: readonly string[]; - readonly properties: Readonly[]; + readonly properties: WatchableValue< + readonly Readonly[] + >; readonly annotationPropertySerializers: AnnotationPropertySerializer[]; constructor( public chunkManager: Borrowed, @@ -529,10 +532,10 @@ export class MultiscaleAnnotationSource ) { super(); this.rank = options.rank; - this.properties = options.properties; + this.properties = new WatchableValue(options.properties); this.annotationPropertySerializers = makeAnnotationPropertySerializers( this.rank, - this.properties, + this.properties.value, ); const segmentFilteredSources: Owned[] = (this.segmentFilteredSources = []); diff --git a/src/annotation/index.ts b/src/annotation/index.ts index f920d1a96..27cc2d3f1 100644 --- a/src/annotation/index.ts +++ b/src/annotation/index.ts @@ -23,6 +23,7 @@ import type { CoordinateSpaceTransform, WatchableCoordinateSpaceTransform, } from "#src/coordinate_transform.js"; +import { WatchableValue } from "#src/trackable_value.js"; import { arraysEqual } from "#src/util/array.js"; import { packColor, @@ -106,6 +107,13 @@ export interface AnnotationNumericPropertySpec min?: number; max?: number; step?: number; + tag?: string; +} + +export interface AnnotationTagPropertySpec + extends AnnotationNumericPropertySpec { + type: "int8"; + tag: string; } export const propertyTypeDataType: Record< @@ -127,6 +135,18 @@ export type AnnotationPropertySpec = | AnnotationColorPropertySpec | AnnotationNumericPropertySpec; +export function isAnnotationNumericPropertySpec( + spec: AnnotationPropertySpec, +): spec is AnnotationNumericPropertySpec { + return spec.type !== "rgb" && spec.type !== "rgba"; +} + +export function isAnnotationTagPropertySpec( + spec: AnnotationPropertySpec, +): spec is AnnotationTagPropertySpec { + return spec.type === "uint8" && spec.tag !== undefined; +} + export interface AnnotationPropertyTypeHandler { serializedBytes(rank: number): number; alignment(rank: number): number; @@ -569,6 +589,7 @@ function parseAnnotationPropertySpec(obj: unknown): AnnotationPropertySpec { ); let enumValues: number[] | undefined; let enumLabels: string[] | undefined; + let tag: string | undefined; switch (type) { case "rgb": case "rgba": @@ -593,6 +614,7 @@ function parseAnnotationPropertySpec(obj: unknown): AnnotationPropertySpec { ), ); } + tag = verifyOptionalObjectProperty(obj, "tag", verifyString); } } return { @@ -602,15 +624,23 @@ function parseAnnotationPropertySpec(obj: unknown): AnnotationPropertySpec { default: defaultValue, enumValues, enumLabels, + tag, } as AnnotationPropertySpec; } function annotationPropertySpecToJson(spec: AnnotationPropertySpec) { const defaultValue = spec.default; + const isNumeric = isAnnotationNumericPropertySpec(spec); + const tag = isNumeric ? spec.tag : undefined; + const enum_values = isNumeric ? spec.enumValues : undefined; + const enum_labels = isNumeric ? spec.enumLabels : undefined; return { id: spec.identifier, description: spec.description, type: spec.type, + tag, + enum_values, + enum_labels, default: defaultValue === 0 ? undefined @@ -1000,7 +1030,7 @@ export const annotationTypeHandlers: Record< export interface AnnotationSchema { rank: number; relationships: readonly string[]; - properties: readonly AnnotationPropertySpec[]; + properties: WatchableValue[]>; } export function annotationToJson( @@ -1020,8 +1050,8 @@ export function annotationToJson( segments.map((x) => x.toString()), ); } - if (schema.properties.length !== 0) { - const propertySpecs = schema.properties; + const propertySpecs = schema.properties.value; + if (propertySpecs.length !== 0) { result.props = annotation.properties.map((prop, i) => annotationPropertyTypeHandlers[propertySpecs[i].type].serializeJson(prop), ); @@ -1061,9 +1091,9 @@ function restoreAnnotation( ); }); const properties = verifyObjectProperty(obj, "props", (propsObj) => { - const propSpecs = schema.properties; + const propSpecs = schema.properties.value; if (propsObj === undefined) return propSpecs.map((x) => x.default); - return parseArray(expectArray(propsObj, schema.properties.length), (x, i) => + return parseArray(expectArray(propsObj, propSpecs.length), (x, i) => annotationPropertyTypeHandlers[propSpecs[i].type].deserializeJson(x), ); }); @@ -1111,13 +1141,15 @@ export class AnnotationSource constructor( rank: number, public readonly relationships: readonly string[] = [], - public readonly properties: Readonly[] = [], + public readonly properties: WatchableValue< + readonly Readonly[] + > = new WatchableValue([]), ) { super(); this.rank_ = rank; this.annotationPropertySerializers = makeAnnotationPropertySerializers( rank, - properties, + properties.value, ); } @@ -1261,7 +1293,9 @@ export class LocalAnnotationSource extends AnnotationSource { constructor( public watchableTransform: WatchableCoordinateSpaceTransform, - properties: AnnotationPropertySpec[], + public readonly properties: WatchableValue< + AnnotationPropertySpec[] + > = new WatchableValue([]), relationships: string[], ) { super(watchableTransform.value.sourceRank, relationships, properties); @@ -1269,8 +1303,46 @@ export class LocalAnnotationSource extends AnnotationSource { this.registerDisposer( watchableTransform.changed.add(() => this.ensureUpdated()), ); + + this.registerDisposer( + properties.changed.add(() => { + this.updateAnnotationPropertySerializers(); + this.changed.dispatch(); + }), + ); + } + + updateAnnotationPropertySerializers() { + this.annotationPropertySerializers = makeAnnotationPropertySerializers( + this.rank_, + this.properties.value, + ); } + addProperty(property: AnnotationPropertySpec) { + this.properties.value.push(property); + for (const annotation of this) { + annotation.properties.push(property.default); + } + this.properties.changed.dispatch(); + } + + removeProperty(identifier: string) { + const propertyIndex = this.properties.value.findIndex( + (x) => x.identifier === identifier, + ); + this.properties.value.splice(propertyIndex, 1); + for (const annotation of this) { + annotation.properties.splice(propertyIndex, 1); + } + this.properties.changed.dispatch(); + } + + getTagProperties = () => { + const { properties } = this; + return properties.value.filter(isAnnotationTagPropertySpec); + }; + ensureUpdated() { const transform = this.watchableTransform.value; const { curCoordinateTransform } = this; @@ -1325,10 +1397,7 @@ export class LocalAnnotationSource extends AnnotationSource { } if (this.rank_ !== sourceRank) { this.rank_ = sourceRank; - this.annotationPropertySerializers = makeAnnotationPropertySerializers( - this.rank_, - this.properties, - ); + this.updateAnnotationPropertySerializers(); } this.changed.dispatch(); } diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index 836fa8dd1..fa46f82ba 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -474,16 +474,18 @@ function AnnotationRenderLayer< private renderHelpers: AnnotationRenderHelper[] = []; private tempChunkPosition: Float32Array; - handleRankChanged() { + handleRankChanged(force = false) { const { rank } = this.base.source; - if (rank === this.curRank) return; + if (!force && rank === this.curRank) return; this.curRank = rank; this.tempChunkPosition = new Float32Array(rank); const { renderHelpers, gl } = this; for (const oldHelper of renderHelpers) { oldHelper.dispose(); } - const { properties } = this.base.source; + const { + properties: { value: properties }, + } = this.base.source; const { displayState } = this.base.state; for (const annotationType of annotationTypes) { const handler = getAnnotationTypeRenderHandler(annotationType); @@ -522,6 +524,12 @@ function AnnotationRenderLayer< }); this.role = base.state.role; this.registerDisposer(base.redrawNeeded.add(this.redrawNeeded.dispatch)); + this.registerDisposer( + base.source.properties.changed.add(() => { + // todo, does it make sense to run this whole function? Or should we pass the watchable value to renderHelperConstructor? + this.handleRankChanged(true); + }), + ); this.handleRankChanged(); this.registerDisposer( this.base.state.displayState.shaderControls.histogramSpecifications.producerVisibility.add( @@ -780,7 +788,9 @@ function AnnotationRenderLayer< transformPickedValue(pickState: PickState) { const { pickedAnnotationBuffer } = pickState; if (pickedAnnotationBuffer === undefined) return undefined; - const { properties } = this.base.source; + const { + properties: { value: properties }, + } = this.base.source; if (properties.length === 0) return undefined; const { pickedAnnotationBufferBaseOffset, diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index bc85aea12..96e76bfa1 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -268,9 +268,10 @@ export class ChunkQueueManager extends SharedObject { if (newState !== oldState) { switch (newState) { case ChunkState.GPU_MEMORY: - // console.log("Copying to GPU", chunk); chunk.copyToGPU(this.gl); - visibleChunksChanged = true; + if (chunk.constructor.name !== "ManifestChunk") { + visibleChunksChanged = true; + } break; case ChunkState.SYSTEM_MEMORY: if (oldState === ChunkState.GPU_MEMORY) { diff --git a/src/credentials_provider/http_request.ts b/src/credentials_provider/http_request.ts index 825dd5b70..1a406e11d 100644 --- a/src/credentials_provider/http_request.ts +++ b/src/credentials_provider/http_request.ts @@ -38,7 +38,10 @@ export async function fetchWithCredentials( credentials: Credentials, requestInit: RequestInit, ) => RequestInit, - errorHandler: (httpError: HttpError, credentials: Credentials) => "refresh", + errorHandler: ( + httpError: HttpError, + credentials: Credentials, + ) => "refresh" | Promise<"refresh">, cancellationToken: CancellationToken = uncancelableToken, ): Promise { let credentials: CredentialsWithGeneration | undefined; @@ -62,7 +65,9 @@ export async function fetchWithCredentials( ); } catch (error) { if (error instanceof HttpError) { - if (errorHandler(error, credentials.credentials) === "refresh") { + if ( + (await errorHandler(error, credentials.credentials)) === "refresh" + ) { if (++credentialsAttempt === maxCredentialsAttempts) throw error; continue; } diff --git a/src/credentials_provider/index.ts b/src/credentials_provider/index.ts index c0aab02d6..4f65b5c5d 100644 --- a/src/credentials_provider/index.ts +++ b/src/credentials_provider/index.ts @@ -18,10 +18,12 @@ * @file Generic facility for providing authentication/authorization credentials. */ +import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; import type { CancellationToken } from "#src/util/cancellation.js"; import { MultipleConsumerCancellationTokenSource } from "#src/util/cancellation.js"; import type { Owned } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; +import type { HttpError } from "#src/util/http_request.js"; import { StringMemoize } from "#src/util/memoize.js"; /** @@ -45,6 +47,27 @@ export abstract class CredentialsProvider extends RefCounted { invalidCredentials?: CredentialsWithGeneration, cancellationToken?: CancellationToken, ) => Promise>; + + errorHandler? = async ( + error: HttpError, + credentials: OAuth2Credentials, + ): Promise<"refresh"> => { + const { status } = error; + if (status === 401) { + // 401: Authorization needed. OAuth2 token may have expired. + return "refresh"; + } + if (status === 403 && !credentials.accessToken) { + // Anonymous access denied. Request credentials. + return "refresh"; + } + if (error instanceof Error && credentials.email !== undefined) { + error.message += ` (Using credentials for ${JSON.stringify( + credentials.email, + )})`; + } + throw error; + }; } export function makeCachedCredentialsGetter( diff --git a/src/credentials_provider/oauth2.ts b/src/credentials_provider/oauth2.ts index 444b5fd95..8e4d07174 100644 --- a/src/credentials_provider/oauth2.ts +++ b/src/credentials_provider/oauth2.ts @@ -59,23 +59,7 @@ export function fetchWithOAuth2Credentials( ); return { ...init, headers }; }, - (error, credentials) => { - const { status } = error; - if (status === 401) { - // 401: Authorization needed. OAuth2 token may have expired. - return "refresh"; - } - if (status === 403 && !credentials.accessToken) { - // Anonymous access denied. Request credentials. - return "refresh"; - } - if (error instanceof Error && credentials.email !== undefined) { - error.message += ` (Using credentials for ${JSON.stringify( - credentials.email, - )})`; - } - throw error; - }, + credentialsProvider.errorHandler!, cancellationToken, ); } diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index 04a1281b6..cd0531fbe 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -20,8 +20,11 @@ import { AnnotationDisplayState, AnnotationLayerState, } from "#src/annotation/annotation_layer_state.js"; +import type { MultiscaleAnnotationSource } from "#src/annotation/frontend_source.js"; import type { + Annotation, AnnotationReference, + AnnotationSource, Line, Point, } from "#src/annotation/index.js"; @@ -36,6 +39,7 @@ import { WithParameters } from "#src/chunk_manager/frontend.js"; import { makeIdentityTransform } from "#src/coordinate_transform.js"; import { WithCredentialsProvider } from "#src/credentials_provider/chunk_source_frontend.js"; import type { CredentialsManager } from "#src/credentials_provider/index.js"; + import type { ChunkedGraphChunkSource as ChunkedGraphChunkSourceInterface, ChunkedGraphChunkSpecification, @@ -81,6 +85,7 @@ import type { import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { LoadedLayerDataSource } from "#src/layer/layer_data_source.js"; import { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { MeshSource } from "#src/mesh/frontend.js"; import type { DisplayDimensionRenderInfo } from "#src/navigation_state.js"; import type { @@ -142,9 +147,11 @@ import { } from "#src/trackable_value.js"; import { AnnotationLayerView, + makeAnnotationListElement, MergedAnnotationStates, PlaceLineTool, } from "#src/ui/annotations.js"; +import { getDefaultAnnotationListBindings } from "#src/ui/default_input_event_bindings.js"; import type { ToolActivation } from "#src/ui/tool.js"; import { LayerTool, @@ -153,10 +160,11 @@ import { registerLegacyTool, registerTool, } from "#src/ui/tool.js"; -import type { Uint64Set } from "#src/uint64_set.js"; +import { Uint64Set } from "#src/uint64_set.js"; import { packColor } from "#src/util/color.js"; import type { Owned } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; +import { removeChildren } from "#src/util/dom.js"; import type { ValueOrError } from "#src/util/error.js"; import { makeValueOrError, valueOrThrow } from "#src/util/error.js"; import { EventActionMap } from "#src/util/event_action_map.js"; @@ -174,7 +182,9 @@ import { verifyEnumString, verifyFiniteFloat, verifyFinitePositiveFloat, + verifyFloatArray, verifyInt, + verifyIntegerArray, verifyNonnegativeInt, verifyObject, verifyObjectProperty, @@ -182,7 +192,9 @@ import { verifyOptionalString, verifyPositiveInt, verifyString, + verifyStringArray, } from "#src/util/json.js"; +import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { getObjectId } from "#src/util/object_id.js"; import { NullarySignal } from "#src/util/signal.js"; import type { @@ -195,9 +207,19 @@ import { } from "#src/util/special_protocol_request.js"; import type { Trackable } from "#src/util/trackable.js"; import { Uint64 } from "#src/util/uint64.js"; +import { DateTimeInputWidget } from "#src/widget/datetime.js"; import { makeDeleteButton } from "#src/widget/delete_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import { makeIcon } from "#src/widget/icon.js"; +import type { LayerControlFactory } from "#src/widget/layer_control.js"; +import { + addLayerControlToOptionsTab, + registerLayerControl, +} from "#src/widget/layer_control.js"; +import type { LayerControlDefinition } from "#src/widget/layer_control.js"; +import { rangeLayerControl } from "#src/widget/layer_control_range.js"; + + function vec4FromVec3(vec: vec3, alpha = 0) { const res = vec4.clone([...vec]); @@ -216,6 +238,7 @@ const RED_COLOR_SEGMENT_PACKED = new Uint64(packColor(RED_COLOR_SEGMENT)); const BLUE_COLOR_SEGMENT_PACKED = new Uint64(packColor(BLUE_COLOR_SEGMENT)); const TRANSPARENT_COLOR_PACKED = new Uint64(packColor(TRANSPARENT_COLOR)); const MULTICUT_OFF_COLOR = vec4.fromValues(0, 0, 0, 0.5); +const WHITE_COLOR = vec3.fromValues(1, 1, 1); class GrapheneMeshSource extends WithParameters( WithCredentialsProvider()(MeshSource), @@ -230,6 +253,8 @@ class GrapheneMeshSource extends WithParameters( class AppInfo { segmentationUrl: string; meshingUrl: string; + l2CacheUrl: string; + table: string; supported_api_versions: number[]; constructor(infoUrl: string, obj: any) { // .../1.0/... is the legacy link style @@ -240,8 +265,11 @@ class AppInfo { if (match === null) { throw Error(`Graph URL invalid: ${infoUrl}`); } - this.segmentationUrl = `${match[1]}/segmentation/api/v${PYCG_APP_VERSION}/table/${match[2]}`; - this.meshingUrl = `${match[1]}/meshing/api/v${PYCG_APP_VERSION}/table/${match[2]}`; + this.table = match[2]; + const { table } = this; + this.segmentationUrl = `${match[1]}/segmentation/api/v${PYCG_APP_VERSION}/table/${table}`; + this.meshingUrl = `${match[1]}/meshing/api/v${PYCG_APP_VERSION}/table/${table}`; + this.l2CacheUrl = `${match[1]}/l2cache/api/v${PYCG_APP_VERSION}`; try { verifyObject(obj); @@ -706,7 +734,7 @@ export class GrapheneDataSource extends PrecomputedDataSource { ); } catch (e) { if (isNotFoundError(e)) { - if (parameters.type === "mesh") { + if (parameters["type"] === "mesh") { console.log("does this happen?"); } } @@ -763,7 +791,7 @@ function makeColoredAnnotationState( const { subsourceEntry } = loadedSubsource; const source = new LocalAnnotationSource( loadedSubsource.loadedDataSource.transform, - [], + new WatchableValue([]), ["associated segments"], ); @@ -815,47 +843,81 @@ function restoreSegmentSelection(obj: any): SegmentSelection { }; } +const segmentSelectionToJSON = (x: SegmentSelection) => { + return { + [SEGMENT_ID_JSON_KEY]: x.segmentId.toJSON(), + [ROOT_ID_JSON_KEY]: x.rootId.toJSON(), + [POSITION_JSON_KEY]: [...x.position], + }; +}; + const ID_JSON_KEY = "id"; -const ERROR_JSON_KEY = "error"; +const SEGMENT_ID_JSON_KEY = "segmentId"; +const ROOT_ID_JSON_KEY = "rootId"; +const POSITION_JSON_KEY = "position"; +const SINK_JSON_KEY = "sink"; +const SOURCE_JSON_KEY = "source"; + const MULTICUT_JSON_KEY = "multicut"; const FOCUS_SEGMENT_JSON_KEY = "focusSegment"; const SINKS_JSON_KEY = "sinks"; const SOURCES_JSON_KEY = "sources"; -const SEGMENT_ID_JSON_KEY = "segmentId"; -const ROOT_ID_JSON_KEY = "rootId"; -const POSITION_JSON_KEY = "position"; + const MERGE_JSON_KEY = "merge"; const MERGES_JSON_KEY = "merges"; const AUTOSUBMIT_JSON_KEY = "autosubmit"; -const SINK_JSON_KEY = "sink"; -const SOURCE_JSON_KEY = "source"; -const MERGED_ROOT_JSON_KEY = "mergedRoot"; const LOCKED_JSON_KEY = "locked"; +const MERGED_ROOT_JSON_KEY = "mergedRoot"; +const ERROR_JSON_KEY = "error"; -class GrapheneState implements Trackable { +const FIND_PATH_JSON_KEY = "findPath"; +const TARGET_JSON_KEY = "target"; +const CENTROIDS_JSON_KEY = "centroids"; +const PRECISION_MODE_JSON_KEY = "precision"; + +class GrapheneState extends RefCounted implements Trackable { changed = new NullarySignal(); public multicutState = new MulticutState(); public mergeState = new MergeState(); + public findPathState = new FindPathState(); constructor() { - this.multicutState.changed.add(() => { - this.changed.dispatch(); - }); - this.mergeState.changed.add(() => { - this.changed.dispatch(); - }); + super(); + this.registerDisposer( + this.multicutState.changed.add(() => { + this.changed.dispatch(); + }), + ); + this.registerDisposer( + this.mergeState.changed.add(() => { + this.changed.dispatch(); + }), + ); + this.registerDisposer( + this.findPathState.changed.add(() => { + this.changed.dispatch(); + }), + ); + } + + replaceSegments(oldValues: Uint64Set, newValues: Uint64Set) { + this.multicutState.replaceSegments(oldValues, newValues); + this.mergeState.replaceSegments(oldValues, newValues); + this.findPathState.replaceSegments(oldValues, newValues); } reset() { this.multicutState.reset(); this.mergeState.reset(); + this.findPathState.reset(); } toJSON() { return { [MULTICUT_JSON_KEY]: this.multicutState.toJSON(), [MERGE_JSON_KEY]: this.mergeState.toJSON(), + [FIND_PATH_JSON_KEY]: this.findPathState.toJSON(), }; } @@ -866,6 +928,9 @@ class GrapheneState implements Trackable { verifyOptionalObjectProperty(x, MERGE_JSON_KEY, (value) => { this.mergeState.restoreState(value); }); + verifyOptionalObjectProperty(x, FIND_PATH_JSON_KEY, (value) => { + this.findPathState.restoreState(value); + }); } } @@ -887,6 +952,31 @@ class MergeState extends RefCounted implements Trackable { this.registerDisposer(this.merges.changed.add(this.changed.dispatch)); } + replaceSegments(oldValues: Uint64Set, newValues: Uint64Set) { + const { + merges: { value: merges }, + } = this; + const newValue = newValues.size === 1 ? [...newValues][0] : undefined; + for (const merge of merges) { + if (merge.source && oldValues.has(merge.source.rootId)) { + if (newValue) { + merge.source.rootId = newValue; + } else { + this.reset(); + return; + } + } + if (merge.sink && oldValues.has(merge.sink.rootId)) { + if (newValue) { + merge.sink.rootId = newValue; + } else { + this.reset(); + return; + } + } + } + } + reset() { this.merges.value = []; this.autoSubmit.reset(); @@ -894,15 +984,6 @@ class MergeState extends RefCounted implements Trackable { toJSON() { const { merges, autoSubmit } = this; - - const segmentSelectionToJSON = (x: SegmentSelection) => { - return { - [SEGMENT_ID_JSON_KEY]: x.segmentId.toJSON(), - [ROOT_ID_JSON_KEY]: x.rootId.toJSON(), - [POSITION_JSON_KEY]: [...x.position], - }; - }; - const mergeToJSON = (x: MergeSubmission) => { const res: any = { [ID_JSON_KEY]: x.id, @@ -910,17 +991,14 @@ class MergeState extends RefCounted implements Trackable { [SINK_JSON_KEY]: segmentSelectionToJSON(x.sink), [SOURCE_JSON_KEY]: segmentSelectionToJSON(x.source!), }; - if (x.mergedRoot) { res[MERGED_ROOT_JSON_KEY] = x.mergedRoot.toJSON(); } if (x.error) { res[ERROR_JSON_KEY] = x.error; } - return res; }; - return { [MERGES_JSON_KEY]: merges.value.filter((x) => x.source).map(mergeToJSON), [AUTOSUBMIT_JSON_KEY]: autoSubmit.toJSON(), @@ -966,6 +1044,147 @@ class MergeState extends RefCounted implements Trackable { } } +class FindPathState extends RefCounted implements Trackable { + changed = new NullarySignal(); + triggerPathUpdate = new NullarySignal(); + + source = new TrackableValue( + undefined, + (x) => x, + ); + target = new TrackableValue( + undefined, + (x) => x, + ); + centroids = new TrackableValue([], (x) => x); + precisionMode = new TrackableBoolean(true); + + get path() { + const path: Line[] = []; + const { + source: { value: source }, + target: { value: target }, + centroids: { value: centroids }, + } = this; + if (!source || !target || centroids.length === 0) { + return path; + } + for (let i = 0; i < centroids.length - 1; i++) { + const pointA = centroids[i]; + const pointB = centroids[i + 1]; + const line: Line = { + pointA: vec3.fromValues(pointA[0], pointA[1], pointA[2]), + pointB: vec3.fromValues(pointB[0], pointB[1], pointB[2]), + id: "", + type: AnnotationType.LINE, + properties: [], + }; + path.push(line); + } + const firstLine: Line = { + pointA: source.position, + pointB: path[0].pointA, + id: "", + type: AnnotationType.LINE, + properties: [], + }; + const lastLine: Line = { + pointA: path[path.length - 1].pointB, + pointB: target.position, + id: "", + type: AnnotationType.LINE, + properties: [], + }; + + return [firstLine, ...path, lastLine]; + } + + constructor() { + super(); + this.registerDisposer( + this.source.changed.add(() => { + this.centroids.reset(); + this.changed.dispatch(); + }), + ); + this.registerDisposer( + this.target.changed.add(() => { + this.centroids.reset(); + this.changed.dispatch(); + }), + ); + this.registerDisposer(this.centroids.changed.add(this.changed.dispatch)); + } + + replaceSegments(oldValues: Uint64Set, newValues: Uint64Set) { + const { + source: { value: source }, + target: { value: target }, + } = this; + const newValue = newValues.size === 1 ? [...newValues][0] : undefined; + const sourceChanged = !!source && oldValues.has(source.rootId); + const targetChanged = !!target && oldValues.has(target.rootId); + if (newValue) { + if (sourceChanged) { + source.rootId = newValue; + } + if (targetChanged) { + target.rootId = newValue; + } + // don't want to fire off multiple changed + if (sourceChanged || targetChanged) { + if (this.centroids.value.length) { + this.centroids.reset(); + this.triggerPathUpdate.dispatch(); + } else { + this.changed.dispatch(); + } + } + } else { + if (sourceChanged || targetChanged) { + this.reset(); + } + } + } + + reset() { + this.source.reset(); + this.target.reset(); + this.centroids.reset(); + this.precisionMode.reset(); + } + + toJSON() { + const { + source: { value: source }, + target: { value: target }, + centroids, + precisionMode, + } = this; + return { + [SOURCE_JSON_KEY]: source ? segmentSelectionToJSON(source) : undefined, + [TARGET_JSON_KEY]: target ? segmentSelectionToJSON(target) : undefined, + [CENTROIDS_JSON_KEY]: centroids.toJSON(), + [PRECISION_MODE_JSON_KEY]: precisionMode.toJSON(), + }; + } + + restoreState(x: any) { + verifyOptionalObjectProperty(x, SOURCE_JSON_KEY, (value) => { + this.source.restoreState(restoreSegmentSelection(value)); + }); + verifyOptionalObjectProperty(x, TARGET_JSON_KEY, (value) => { + this.target.restoreState(restoreSegmentSelection(value)); + }); + verifyOptionalObjectProperty(x, CENTROIDS_JSON_KEY, (value) => { + this.centroids.restoreState(value); + }); + verifyOptionalObjectProperty(x, PRECISION_MODE_JSON_KEY, (value) => { + this.precisionMode.restoreState(value); + }); + } +} + class MulticutState extends RefCounted implements Trackable { changed = new NullarySignal(); @@ -996,8 +1215,30 @@ class MulticutState extends RefCounted implements Trackable { this.registerDisposer(this.sources.changed.add(this.changed.dispatch)); } + replaceSegments(oldValues: Uint64Set, newValues: Uint64Set) { + const newValue = newValues.size === 1 ? [...newValues][0] : undefined; + const { + focusSegment: { value: focusSegment }, + } = this; + + if (focusSegment && oldValues.has(focusSegment)) { + if (newValue) { + this.focusSegment.value = newValue; + for (const sink of this.sinks) { + sink.rootId = newValue; + } + for (const source of this.sources) { + source.rootId = newValue; + } + this.changed.dispatch(); + } else { + this.reset(); + } + } + } + reset() { - this.focusSegment.value = undefined; + this.focusSegment.reset(); this.blueGroup.value = false; this.sinks.clear(); this.sources.clear(); @@ -1005,15 +1246,6 @@ class MulticutState extends RefCounted implements Trackable { toJSON() { const { focusSegment, sinks, sources } = this; - - const segmentSelectionToJSON = (x: SegmentSelection) => { - return { - [SEGMENT_ID_JSON_KEY]: x.segmentId.toJSON(), - [ROOT_ID_JSON_KEY]: x.rootId.toJSON(), - [POSITION_JSON_KEY]: [...x.position], - }; - }; - return { [FOCUS_SEGMENT_JSON_KEY]: focusSegment.toJSON(), [SINKS_JSON_KEY]: [...sinks].map(segmentSelectionToJSON), @@ -1080,6 +1312,7 @@ class MulticutState extends RefCounted implements Trackable { class GraphConnection extends SegmentationGraphSourceConnection { public annotationLayerStates: AnnotationLayerState[] = []; public mergeAnnotationState: AnnotationLayerState; + public findPathAnnotationState: AnnotationLayerState; constructor( public graph: GrapheneGraphSource, @@ -1089,28 +1322,55 @@ class GraphConnection extends SegmentationGraphSourceConnection { ) { super(graph, layer.displayState.segmentationGroupState.value); const segmentsState = layer.displayState.segmentationGroupState.value; - segmentsState.selectedSegments.changed.add( - (segmentIds: Uint64[] | Uint64 | null, add: boolean) => { - if (segmentIds !== null) { - segmentIds = Array().concat(segmentIds); - } - this.selectedSegmentsChanged(segmentIds, add); - }, + this.previousVisibleSegmentCount = segmentsState.visibleSegments.size; + this.registerDisposer( + segmentsState.selectedSegments.changed.add( + (segmentIds: Uint64[] | Uint64 | null, add: boolean) => { + if (segmentIds !== null) { + segmentIds = Array().concat(segmentIds); + } + this.selectedSegmentsChanged(segmentIds, add); + }, + ), ); - segmentsState.visibleSegments.changed.add( - (segmentIds: Uint64[] | Uint64 | null, add: boolean) => { - if (segmentIds !== null) { - segmentIds = Array().concat(segmentIds); - } - this.visibleSegmentsChanged(segmentIds, add); - }, + this.registerDisposer( + segmentsState.visibleSegments.changed.add( + (segmentIds: Uint64[] | Uint64 | null, add: boolean) => { + if (segmentIds !== null) { + segmentIds = Array().concat(segmentIds); + } + this.visibleSegmentsChanged(segmentIds, add); + }, + ), ); const { annotationLayerStates, - state: { multicutState }, + state: { multicutState, mergeState, findPathState }, } = this; + + const { timestamp } = segmentsState; + this.registerDisposer( + timestamp.changed.add(async () => { + const nonLatestRoots = await this.graph.graphServer.filterLatestRoots( + [...segmentsState.selectedSegments], + timestamp.value, + true, + ); + segmentsState.selectedSegments.delete(nonLatestRoots); + const unsetTimestamp = timestamp.value === undefined; + if (unsetTimestamp) { + const { + focusSegment: { value: focusSegment }, + } = state.multicutState; + if (focusSegment) { + segmentsState.visibleSegments.add(focusSegment); + } + } + }), + ); + const loadedSubsource = getGraphLoadedSubsource(layer)!; const redGroup = makeColoredAnnotationState( layer, @@ -1151,80 +1411,169 @@ class GraphConnection extends SegmentationGraphSourceConnection { } // initialize source changes - mergeAnnotationState.source.childAdded.add((x) => { - const annotation = x as Line; - const relatedSegments = annotation.relatedSegments![0]; - const visibles = relatedSegments.map((x) => visibleSegments.has(x)); - if (visibles[0] === false) { - setTimeout(() => { - const { tool } = layer; - if (tool.value instanceof MergeSegmentsPlaceLineTool) { - tool.value.deactivate(); - } - }, 0); - StatusMessage.showTemporaryMessage("Cannot merge a hidden segment."); - } else if (merges.value.length < MAX_MERGE_COUNT) { - merges.value = [...merges.value, lineToSubmission(annotation, true)]; - } else { - setTimeout(() => { - const { tool } = layer; - if (tool.value instanceof MergeSegmentsPlaceLineTool) { - tool.value.deactivate(); - } - }, 0); - StatusMessage.showTemporaryMessage( - `Maximum of ${MAX_MERGE_COUNT} simultanous merges allowed.`, - ); - } - }); - - mergeAnnotationState.source.childCommitted.add((x) => { - const ref = mergeAnnotationState.source.getReference(x); - const annotation = ref.value as Line | undefined; - if (annotation) { + this.registerDisposer( + mergeAnnotationState.source.childAdded.add((x) => { + const annotation = x as Line; const relatedSegments = annotation.relatedSegments![0]; const visibles = relatedSegments.map((x) => visibleSegments.has(x)); - if (relatedSegments.length < 4) { - mergeAnnotationState.source.delete(ref); + if (visibles[0] === false) { + setTimeout(() => { + const { tool } = layer; + if (tool.value instanceof MergeSegmentsPlaceLineTool) { + tool.value.deactivate(); + } + }, 0); StatusMessage.showTemporaryMessage( - "Cannot merge segment with itself.", + `Cannot merge a hidden segment.`, ); - } - if (visibles[2] === false) { - mergeAnnotationState.source.delete(ref); + } else if (merges.value.length < MAX_MERGE_COUNT) { + merges.value = [ + ...merges.value, + lineToSubmission(annotation, true), + ]; + } else { + setTimeout(() => { + const { tool } = layer; + if (tool.value instanceof MergeSegmentsPlaceLineTool) { + tool.value.deactivate(); + } + }, 0); StatusMessage.showTemporaryMessage( - "Cannot merge a hidden segment.", + `Maximum of ${MAX_MERGE_COUNT} simultanous merges allowed.`, ); } - const existingSubmission = merges.value.find((x) => x.id === ref.id); - if (existingSubmission && !existingSubmission?.locked) { - // how would it be locked? - const newSubmission = lineToSubmission(annotation, false); - existingSubmission.sink = newSubmission.sink; - existingSubmission.source = newSubmission.source; - merges.changed.dispatch(); - if (autoSubmit.value) { - this.bulkMerge([existingSubmission]); + }), + ); + + this.registerDisposer( + mergeAnnotationState.source.childCommitted.add((x) => { + const ref = mergeAnnotationState.source.getReference(x); + const annotation = ref.value as Line | undefined; + if (annotation) { + const relatedSegments = annotation.relatedSegments![0]; + const visibles = relatedSegments.map((x) => visibleSegments.has(x)); + if (relatedSegments.length < 4) { + mergeAnnotationState.source.delete(ref); + StatusMessage.showTemporaryMessage( + `Cannot merge segment with itself.`, + ); + } + if (visibles[2] === false) { + mergeAnnotationState.source.delete(ref); + StatusMessage.showTemporaryMessage( + `Cannot merge a hidden segment.`, + ); + } + const existingSubmission = merges.value.find( + (x) => x.id === ref.id, + ); + if (existingSubmission && !existingSubmission?.locked) { + // how would it be locked? + const newSubmission = lineToSubmission(annotation, false); + existingSubmission.sink = newSubmission.sink; + existingSubmission.source = newSubmission.source; + merges.changed.dispatch(); + if (autoSubmit.value) { + this.bulkMerge([existingSubmission]); + } } } - } - ref.dispose(); - }); + ref.dispose(); + }), + ); - mergeAnnotationState.source.childDeleted.add((id) => { - let changed = false; - const filtered = merges.value.filter((x) => { - const keep = x.id !== id || x.locked; - if (!keep) { - changed = true; + this.registerDisposer( + mergeAnnotationState.source.childDeleted.add((id) => { + let changed = false; + const filtered = merges.value.filter((x) => { + const keep = x.id !== id || x.locked; + if (!keep) { + changed = true; + } + return keep; + }); + if (changed) { + merges.value = filtered; } - return keep; + }), + ); + } + + const findPathGroup = makeColoredAnnotationState( + layer, + loadedSubsource, + "findpath", + WHITE_COLOR, + ); + this.findPathAnnotationState = findPathGroup; + findPathGroup.source.childDeleted.add((annotationId) => { + if ( + findPathState.source.value?.annotationReference?.id === annotationId + ) { + findPathState.source.value = undefined; + } + if ( + findPathState.target.value?.annotationReference?.id === annotationId + ) { + findPathState.target.value = undefined; + } + }); + const findPathChanged = () => { + const { path, source, target } = findPathState; + const annotationSource = findPathGroup.source; + if (source.value && !source.value.annotationReference) { + addSelection(annotationSource, source.value, "find path source"); + } + if (target.value && !target.value.annotationReference) { + addSelection(annotationSource, target.value, "find path target"); + } + for (const annotation of annotationSource) { + if ( + annotation.id !== source.value?.annotationReference?.id && + annotation.id !== target.value?.annotationReference?.id + ) { + annotationSource.delete(annotationSource.getReference(annotation.id)); + } + } + for (const line of path) { + // line.id = ''; // TODO, is it a bug that this is necessary? annotationMap is empty if I + // step through it but logging shows it isn't empty + annotationSource.add(line); + } + }; + this.registerDisposer(findPathState.changed.add(findPathChanged)); + this.registerDisposer( + findPathState.triggerPathUpdate.add(() => { + const loadedSubsource = getGraphLoadedSubsource(this.layer)!; + const annotationToNanometers = + loadedSubsource.loadedDataSource.transform.inputSpace.value.scales.map( + (x) => x / 1e-9, + ); + this.submitFindPath( + findPathState.precisionMode.value, + annotationToNanometers, + ).then((success) => { + success; }); - if (changed) { - merges.value = filtered; + }), + ); + findPathChanged(); // initial state + this.registerDisposer( + state.changed.add(() => { + if (segmentsState.timestamp.value === undefined) { + if ( + multicutState.focusSegment.value || + mergeState.merges.value.length > 0 + ) { + // remind me why want to add ourselves compared to keeping it empty + // if it is non empty, graphene knows there is a tool locking it + segmentsState.timestampOwner.add(layer.managedLayer.name); + } else { + segmentsState.timestampOwner.delete(layer.managedLayer.name); + } } - }); - } + }), + ); } createRenderLayers( @@ -1246,12 +1595,21 @@ class GraphConnection extends SegmentationGraphSourceConnection { private lastDeselectionMessage: StatusMessage | undefined; private lastDeselectionMessageExists = false; + private previousVisibleSegmentCount: number; + private visibleSegmentsChanged(segments: Uint64[] | null, added: boolean) { const { segmentsState } = this; + const { state } = this.graph; const { focusSegment: { value: focusSegment }, - } = this.graph.state.multicutState; - if (focusSegment && !segmentsState.visibleSegments.has(focusSegment)) { + } = state.multicutState; + const { timestamp } = segmentsState; + const unsetTimestamp = timestamp.value === undefined; + if ( + unsetTimestamp && + focusSegment && + !segmentsState.visibleSegments.has(focusSegment) + ) { if (segmentsState.selectedSegments.has(focusSegment)) { StatusMessage.showTemporaryMessage( `Can't hide active multicut segment.`, @@ -1263,7 +1621,6 @@ class GraphConnection extends SegmentationGraphSourceConnection { 3000, ); } - segmentsState.selectedSegments.add(focusSegment); segmentsState.visibleSegments.add(focusSegment); if (segments) { segments = segments.filter( @@ -1272,16 +1629,18 @@ class GraphConnection extends SegmentationGraphSourceConnection { } } if (segments === null) { - const leafSegmentCount = this.segmentsState.selectedSegments.size; this.segmentsState.segmentEquivalences.clear(); StatusMessage.showTemporaryMessage( - `Hid all ${leafSegmentCount} segments.`, + `Hid all ${this.previousVisibleSegmentCount} segment(s).`, 3000, ); return; } for (const segmentId of segments) { - if (!added) { + if ( + !added && + !isBaseSegmentId(segmentId, this.graph.info.graph.nBitsForLayerId) + ) { const segmentCount = [ ...segmentsState.segmentEquivalences.setElements(segmentId), ].length; // Approximation @@ -1291,7 +1650,7 @@ class GraphConnection extends SegmentationGraphSourceConnection { this.lastDeselectionMessageExists = false; } this.lastDeselectionMessage = StatusMessage.showMessage( - `Hid ${segmentCount} segments.`, + `Hid ${segmentCount} segment(s).`, ); this.lastDeselectionMessageExists = true; setTimeout(() => { @@ -1302,6 +1661,7 @@ class GraphConnection extends SegmentationGraphSourceConnection { }, 2000); } } + this.previousVisibleSegmentCount = segmentsState.visibleSegments.size; } private selectedSegmentsChanged(segments: Uint64[] | null, added: boolean) { @@ -1309,7 +1669,7 @@ class GraphConnection extends SegmentationGraphSourceConnection { if (segments === null) { const leafSegmentCount = this.segmentsState.selectedSegments.size; StatusMessage.showTemporaryMessage( - `Deselected all ${leafSegmentCount} segments.`, + `Deselected all ${leafSegmentCount} segment(s).`, 3000, ); return; @@ -1321,13 +1681,15 @@ class GraphConnection extends SegmentationGraphSourceConnection { ); const segmentConst = segmentId.clone(); if (added && isBaseSegment) { - this.graph.getRoot(segmentConst).then((rootId) => { - if (segmentsState.visibleSegments.has(segmentConst)) { - segmentsState.visibleSegments.add(rootId); - } - segmentsState.selectedSegments.delete(segmentConst); - segmentsState.selectedSegments.add(rootId); - }); + this.graph + .getRoot(segmentConst, this.layer.displayState.stopLayer.value, segmentsState.timestamp.value) + .then((rootId) => { + if (segmentsState.visibleSegments.has(segmentConst)) { + segmentsState.visibleSegments.add(rootId); + } + segmentsState.selectedSegments.delete(segmentConst); + segmentsState.selectedSegments.add(rootId); + }); } } } @@ -1347,7 +1709,7 @@ class GraphConnection extends SegmentationGraphSourceConnection { const graphSubsource = subsources.filter( (subsource) => subsource.id === "graph", )[0]; - if (graphSubsource?.subsource.segmentationGraph) { + if (graphSubsource && graphSubsource.subsource.segmentationGraph) { if (graphSubsource.subsource.segmentationGraph !== this.graph) { continue; } @@ -1386,27 +1748,35 @@ class GraphConnection extends SegmentationGraphSourceConnection { 7000, ); return false; + } else { + const splitRoots = await this.graph.graphServer.splitSegments( + [...sinks].map((x) => selectionInNanometers(x, annotationToNanometers)), + [...sources].map((x) => + selectionInNanometers(x, annotationToNanometers), + ), + ); + if (splitRoots.length === 0) { + StatusMessage.showTemporaryMessage(`No split found.`, 3000); + return false; + } else { + const focusSegment = multicutState.focusSegment.value!; + multicutState.reset(); // need to clear the focus segment before deleting the multicut segment + const { segmentsState } = this; + segmentsState.selectedSegments.delete(focusSegment); + for (const segment of [...sinks, ...sources]) { + segmentsState.selectedSegments.delete(segment.rootId); + } + this.meshAddNewSegments(splitRoots); + segmentsState.selectedSegments.add(splitRoots); + segmentsState.visibleSegments.add(splitRoots); + const oldValues = new Uint64Set(); + oldValues.add(focusSegment); + const newValues = new Uint64Set(); + newValues.add(splitRoots); + this.state.replaceSegments(oldValues, newValues); + return true; + } } - const splitRoots = await this.graph.graphServer.splitSegments( - [...sinks], - [...sources], - annotationToNanometers, - ); - if (splitRoots.length === 0) { - StatusMessage.showTemporaryMessage("No split found.", 3000); - return false; - } - const focusSegment = multicutState.focusSegment.value!; - multicutState.reset(); // need to clear the focus segment before deleting the multicut segment - const { segmentsState } = this; - segmentsState.selectedSegments.delete(focusSegment); - for (const segment of [...sinks, ...sources]) { - segmentsState.selectedSegments.delete(segment.rootId); - } - this.meshAddNewSegments(splitRoots); - segmentsState.selectedSegments.add(splitRoots); - segmentsState.visibleSegments.add(splitRoots); - return true; } deleteMergeSubmission = (submission: MergeSubmission) => { @@ -1421,7 +1791,6 @@ class GraphConnection extends SegmentationGraphSourceConnection { submission: MergeSubmission, attempts = 1, ): Promise => { - this.graph; const loadedSubsource = getGraphLoadedSubsource(this.layer)!; const annotationToNanometers = loadedSubsource.loadedDataSource.transform.inputSpace.value.scales.map( @@ -1430,11 +1799,17 @@ class GraphConnection extends SegmentationGraphSourceConnection { submission.error = undefined; for (let i = 1; i <= attempts; i++) { try { - return await this.graph.graphServer.mergeSegments( - submission.sink, - submission.source!, - annotationToNanometers, + const newRoot = await this.graph.graphServer.mergeSegments( + selectionInNanometers(submission.sink, annotationToNanometers), + selectionInNanometers(submission.source!, annotationToNanometers), ); + const oldValues = new Uint64Set(); + oldValues.add(submission.sink.rootId); + oldValues.add(submission.source!.rootId); + const newValues = new Uint64Set(); + newValues.add(newRoot); + this.state.replaceSegments(oldValues, newValues); + return newRoot; } catch (err) { if (i === attempts) { submission.error = err.message || "unknown"; @@ -1457,20 +1832,6 @@ class GraphConnection extends SegmentationGraphSourceConnection { return; } const segmentsToRemove: Uint64[] = []; - const replaceSegment = (a: Uint64, b: Uint64) => { - segmentsToRemove.push(a); - for (const submission of submissions) { - if ( - submission.source && - Uint64.equal(submission.source.rootId, a) - ) { - submission.source.rootId = b; - } - if (Uint64.equal(submission.sink.rootId, a)) { - submission.sink.rootId = b; - } - } - }; let completed = 0; let activeLoops = 0; const loop = (completedAt: number, pending: MergeSubmission[]) => { @@ -1491,10 +1852,13 @@ class GraphConnection extends SegmentationGraphSourceConnection { submission.locked = true; submission.status = "trying..."; merges.changed.dispatch(); + const segments = [ + submission.source!.rootId, + submission.sink.rootId, + ]; this.submitMerge(submission, 3) .then((mergedRoot) => { - replaceSegment(submission.source!.rootId, mergedRoot); - replaceSegment(submission.sink.rootId, mergedRoot); + segmentsToRemove.push(...segments); submission.status = "done"; submission.mergedRoot = mergedRoot; merges.changed.dispatch(); @@ -1531,24 +1895,53 @@ class GraphConnection extends SegmentationGraphSourceConnection { } else if (submission.mergedRoot) { segmentsToAdd.push(submission.mergedRoot); } + const latestRoots = + await this.graph.graphServer.filterLatestRoots(segmentsToAdd); + const segmentsState = + this.layer.displayState.segmentationGroupState.value; + const { visibleSegments, selectedSegments } = segmentsState; + selectedSegments.delete(segmentsToRemove); + this.meshAddNewSegments(latestRoots); + selectedSegments.add(latestRoots); + visibleSegments.add(latestRoots); + merges.changed.dispatch(); } - const latestRoots = - await this.graph.graphServer.filterLatestRoots(segmentsToAdd); const segmentsState = this.layer.displayState.segmentationGroupState.value; const { visibleSegments, selectedSegments } = segmentsState; selectedSegments.delete(segmentsToRemove); - this.meshAddNewSegments(latestRoots); + const latestRoots = + await this.graph.graphServer.filterLatestRoots(segmentsToAdd); selectedSegments.add(latestRoots); visibleSegments.add(latestRoots); merges.changed.dispatch(); } + + async submitFindPath( + precisionMode: boolean, + annotationToNanometers: Float64Array, + ): Promise { + const { + state: { findPathState }, + } = this; + const { source, target } = findPathState; + if (!source.value || !target.value) return false; + const centroids = await this.graph.findPath( + source.value, + target.value, + precisionMode, + annotationToNanometers, + ); + StatusMessage.showTemporaryMessage("Path found!", 5000); + findPathState.centroids.value = centroids; + return true; + } } async function parseGrapheneError(e: HttpError) { if (e.response) { let msg: string; if (e.response.headers.get("content-type") === "application/json") { - msg = (await e.response.json()).message; + msg = (await e.response.json())["message"]; } else { msg = await e.response.text(); } @@ -1565,7 +1958,7 @@ async function withErrorMessageHTTP( }, ): Promise { let status: StatusMessage | undefined = undefined; - let dispose = () => {}; + let dispose = () => { }; if (options.initialMessage) { status = new StatusMessage(true); status.setText(options.initialMessage); @@ -1578,34 +1971,57 @@ async function withErrorMessageHTTP( } catch (e) { if (e instanceof HttpError && e.response) { const { errorPrefix = "" } = options; - const msg = await parseGrapheneError(e); - if (msg) { - if (!status) { - status = new StatusMessage(true); - } - status.setErrorMessage(errorPrefix + msg); - status.setVisible(true); - throw new Error(`[${e.response.status}] ${errorPrefix}${msg}`); + const msg = (await parseGrapheneError(e)) || "unknown error"; + if (!status) { + status = new StatusMessage(true); } + status.setErrorMessage(errorPrefix + msg); + status.setVisible(true); + throw new Error(`[${e.response.status}] ${errorPrefix}${msg}`); } throw e; } } +const selectionInNanometers = ( + selection: SegmentSelection, + annotationToNanometers: Float64Array, +): SegmentSelection => { + const { rootId, segmentId, position } = selection; + return { + rootId, + segmentId, + position: position.map((val, i) => val * annotationToNanometers[i]), + }; +}; + export const GRAPH_SERVER_NOT_SPECIFIED = Symbol("Graph Server Not Specified."); class GrapheneGraphServerInterface { constructor( private url: string, private credentialsProvider: SpecialProtocolCredentialsProvider, - ) {} + ) { } + + async getTimestampLimit() { + const response = await cancellableFetchSpecialOk( + this.credentialsProvider, + `${this.url}/oldest_timestamp`, + {}, + responseJson, + ); + const isoString = verifyObjectProperty(response, "iso", verifyString); + return new Date(isoString).valueOf(); + } - async getRoot(segment: Uint64, timestamp = "") { - const timestampEpoch = new Date(timestamp).valueOf() / 1000; + async getRoot(segment: Uint64, timestamp = 0, stop_layer: number | undefined = undefined) { + const timestampEpoch = timestamp / 1000; - const url = `${this.url}/node/${String(segment)}/root?int64_as_str=1${ - Number.isNaN(timestampEpoch) ? "" : `×tamp=${timestampEpoch}` - }`; + let url = `${this.url}/node/${String(segment)}/root?int64_as_str=1${timestamp > 0 ? `×tamp=${timestampEpoch}` : "" + }`; + if (stop_layer !== undefined) { + url += "&stop_layer=" + stop_layer; + } const promise = cancellableFetchSpecialOk( this.credentialsProvider, @@ -1616,16 +2032,15 @@ class GrapheneGraphServerInterface { const response = await withErrorMessageHTTP(promise, { initialMessage: `Retrieving root for segment ${segment}`, - errorPrefix: "Could not fetch root: ", + errorPrefix: `Could not fetch root: `, }); const jsonResp = await response.json(); - return Uint64.parseString(jsonResp.root_id); + return Uint64.parseString(jsonResp["root_id"]); } async mergeSegments( first: SegmentSelection, second: SegmentSelection, - annotationToNanometers: Float64Array, ): Promise { const { url } = this; if (url === "") { @@ -1638,14 +2053,8 @@ class GrapheneGraphServerInterface { { method: "POST", body: JSON.stringify([ - [ - String(first.segmentId), - ...first.position.map((val, i) => val * annotationToNanometers[i]), - ], - [ - String(second.segmentId), - ...second.position.map((val, i) => val * annotationToNanometers[i]), - ], + [String(first.segmentId), ...first.position], + [String(second.segmentId), ...second.position], ]), }, responseIdentity, @@ -1654,7 +2063,7 @@ class GrapheneGraphServerInterface { try { const response = await promise; const jsonResp = await response.json(); - return Uint64.parseString(jsonResp.new_root_ids[0]); + return Uint64.parseString(jsonResp["new_root_ids"][0]); } catch (e) { if (e instanceof HttpError) { const msg = await parseGrapheneError(e); @@ -1667,7 +2076,6 @@ class GrapheneGraphServerInterface { async splitSegments( first: SegmentSelection[], second: SegmentSelection[], - annotationToNanometers: Float64Array, ): Promise { const { url } = this; if (url === "") { @@ -1680,14 +2088,8 @@ class GrapheneGraphServerInterface { { method: "POST", body: JSON.stringify({ - sources: first.map((x) => [ - String(x.segmentId), - ...x.position.map((val, i) => val * annotationToNanometers[i]), - ]), - sinks: second.map((x) => [ - String(x.segmentId), - ...x.position.map((val, i) => val * annotationToNanometers[i]), - ]), + sources: first.map((x) => [String(x.segmentId), ...x.position]), + sinks: second.map((x) => [String(x.segmentId), ...x.position]), }), }, responseIdentity, @@ -1698,50 +2100,116 @@ class GrapheneGraphServerInterface { errorPrefix: "Split failed: ", }); const jsonResp = await response.json(); - const final: Uint64[] = new Array(jsonResp.new_root_ids.length); + const final: Uint64[] = new Array(jsonResp["new_root_ids"].length); for (let i = 0; i < final.length; ++i) { - final[i] = Uint64.parseString(jsonResp.new_root_ids[i]); + final[i] = Uint64.parseString(jsonResp["new_root_ids"][i]); } return final; } - async filterLatestRoots(segments: Uint64[]): Promise { - const url = `${this.url}/is_latest_roots`; - + async filterLatestRoots( + segments: Uint64[], + timestamp = 0, + flipResult = false, + ): Promise { + const timestampEpoch = timestamp / 1000; + const url = `${this.url}/is_latest_roots${timestamp > 0 ? `?timestamp=${timestampEpoch}` : "" + }`; const promise = cancellableFetchSpecialOk( this.credentialsProvider, url, { method: "POST", - body: JSON.stringify({ - node_ids: segments.map((x) => x.toJSON()), - }), + body: JSON.stringify({ node_ids: segments.map((x) => x.toJSON()) }), }, responseIdentity, ); - const response = await withErrorMessageHTTP(promise, { - errorPrefix: "Could not check latest: ", + errorPrefix: `Could not check latest: `, }); const jsonResp = await response.json(); - const res: Uint64[] = []; - for (const [i, isLatest] of jsonResp.is_latest.entries()) { - if (isLatest) { + for (const [i, isLatest] of jsonResp["is_latest"].entries()) { + if (isLatest !== flipResult) { res.push(segments[i]); } } return res; } + + async findPath( + first: SegmentSelection, + second: SegmentSelection, + precisionMode: boolean, + ) { + const { url } = this; + if (url === "") { + return Promise.reject(GRAPH_SERVER_NOT_SPECIFIED); + } + const promise = cancellableFetchSpecialOk( + this.credentialsProvider, + `${url}/graph/find_path?int64_as_str=1&precision_mode=${Number( + precisionMode, + )}`, + { + method: "POST", + body: JSON.stringify([ + [String(first.rootId), ...first.position], + [String(second.rootId), ...second.position], + ]), + }, + responseIdentity, + ); + + const response = await withErrorMessageHTTP(promise, { + initialMessage: `Finding path between ${first.segmentId} and ${second.segmentId}`, + errorPrefix: "Path finding failed: ", + }); + const jsonResponse = await response.json(); + const supervoxelCentroidsKey = "centroids_list"; + const centroids = verifyObjectProperty( + jsonResponse, + supervoxelCentroidsKey, + (x) => parseArray(x, verifyFloatArray), + ); + const missingL2IdsKey = "failed_l2_ids"; + const missingL2Ids = jsonResponse[missingL2IdsKey]; + if (missingL2Ids && missingL2Ids.length > 0) { + StatusMessage.showTemporaryMessage( + "Some level 2 meshes are missing, so the path shown may have a poor level of detail.", + ); + } + const l2_path = verifyOptionalObjectProperty( + jsonResponse, + "l2_path", + verifyStringArray, + ); + return { + centroids, + l2_path, + }; + } } +export const LAYER_CONTROLS: LayerControlDefinition[] = [ + { + label: "Stop Layer", // Adjust the label as needed + toolJson: json_keys.STOP_LAYER, + // ... other properties (toolJson, isValid, title) based on your requirements + ...rangeLayerControl((layer) => ({ + value: layer.displayState.stopLayer, + options: { min: 0.0, max: 10, step: 1.0 } + })), // Integrate your getter function + }] + class GrapheneGraphSource extends SegmentationGraphSource { - private connections = new Set(); public graphServer: GrapheneGraphServerInterface; + private l2CacheAvailable: boolean | undefined = undefined; + public timestampLimit = new TrackableValue(0, (x) => x); constructor( public info: GrapheneMultiscaleVolumeInfo, - credentialsProvider: SpecialProtocolCredentialsProvider, + private credentialsProvider: SpecialProtocolCredentialsProvider, private chunkSource: GrapheneMultiscaleVolumeChunkSource, public state: GrapheneState, ) { @@ -1750,24 +2218,15 @@ class GrapheneGraphSource extends SegmentationGraphSource { info.app!.segmentationUrl, credentialsProvider, ); + this.graphServer.getTimestampLimit().then((limit) => { + this.timestampLimit.value = limit; + }); } connect( layer: SegmentationUserLayer, ): Owned { - const connection = new GraphConnection( - this, - layer, - this.chunkSource, - this.state, - ); - - this.connections.add(connection); - connection.registerDisposer(() => { - this.connections.delete(connection); - }); - - return connection; + return new GraphConnection(this, layer, this.chunkSource, this.state); } get visibleSegmentEquivalencePolicy() { @@ -1777,8 +2236,84 @@ class GrapheneGraphSource extends SegmentationGraphSource { ); } - getRoot(segment: Uint64) { - return this.graphServer.getRoot(segment); + async isL2CacheUrlAvailable() { + if (this.l2CacheAvailable !== undefined) { + return this.l2CacheAvailable; + } + try { + const { l2CacheUrl, table } = this.info.app; + const tableMapping = await cancellableFetchSpecialOk( + undefined, + `${l2CacheUrl}/table_mapping`, + {}, + responseJson, + ); + this.l2CacheAvailable = !!(tableMapping && tableMapping[table]); + return this.l2CacheAvailable; + } catch (e) { + console.error("e", e); + return false; + } + } + + getRoot(segment: Uint64, stop_layer: number, timestamp?: number,) { + if (stop_layer > 0) { + return this.graphServer.getRoot(segment, timestamp, stop_layer); + } + else { + return this.graphServer.getRoot(segment, timestamp); + } + } + + async findPath( + first: SegmentSelection, + second: SegmentSelection, + precisionMode: boolean, + annotationToNanometers: Float64Array, + ): Promise { + const { l2CacheUrl, table } = this.info.app; + const l2CacheAvailable = + precisionMode && (await this.isL2CacheUrlAvailable()); // don't check if available if we don't need it + let { centroids, l2_path } = await this.graphServer.findPath( + selectionInNanometers(first, annotationToNanometers), + selectionInNanometers(second, annotationToNanometers), + precisionMode && !l2CacheAvailable, + ); + if (precisionMode && l2CacheAvailable && l2_path) { + const repCoordinatesUrl = `${l2CacheUrl}/table/${table}/attributes`; + try { + const res = await cancellableFetchSpecialOk( + this.credentialsProvider, + repCoordinatesUrl, + { + method: "POST", + body: JSON.stringify({ + l2_ids: l2_path, + }), + }, + responseJson, + ); + + // many reasons why an l2 id might not have info + // l2 cache has a process that takes time for new ids (even hours) + // maybe a small fraction have no info + // sometime l2 is so small (single voxel), it is ignored by l2 + // best to just drop those points + centroids = l2_path + .map((id) => { + return verifyOptionalObjectProperty(res, id, (x) => { + return verifyIntegerArray(x["rep_coord_nm"]); + }); + }) + .filter((x): x is number[] => x !== undefined); + } catch (e) { + console.log("e", e); + } + } + const centroidsTransformed = centroids.map((point: number[]) => { + return point.map((val, i) => val / annotationToNanometers[i]); + }); + return centroidsTransformed; } tabContents( @@ -1790,6 +2325,9 @@ class GrapheneGraphSource extends SegmentationGraphSource { parent.style.display = "contents"; const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-segmentation-toolbox"; + parent.appendChild( + addLayerControlToOptionsTab(tab, layer, tab.visibility, timeControl), + ); toolbox.appendChild( makeToolButton(context, layer.toolBinder, { toolJson: GRAPHENE_MULTICUT_SEGMENTS_TOOL_ID, @@ -1804,7 +2342,22 @@ class GrapheneGraphSource extends SegmentationGraphSource { title: "Merge segments", }), ); + toolbox.appendChild( + makeToolButton(context, layer.toolBinder, { + toolJson: GRAPHENE_FIND_PATH_TOOL_ID, + label: "Find Path", + title: "Find Path", + }), + ); + for (const control of LAYER_CONTROLS) { + toolbox.appendChild( + addLayerControlToOptionsTab(tab, layer, tab.visibility, control), + ); + } + + parent.appendChild(toolbox); + parent.appendChild( context.registerDisposer( new MulticutAnnotationLayerView(layer, layer.annotationDisplayState), @@ -1838,12 +2391,21 @@ class GrapheneGraphSource extends SegmentationGraphSource { } } + class ChunkedGraphChunkSource extends SliceViewChunkSource - implements ChunkedGraphChunkSourceInterface -{ + implements ChunkedGraphChunkSourceInterface { spec: ChunkedGraphChunkSpecification; OPTIONS: { spec: ChunkedGraphChunkSpecification }; + + constructor( + chunkManager: ChunkManager, + options: { + spec: ChunkedGraphChunkSpecification; + }, + ) { + super(chunkManager, options); + } } class GrapheneChunkedGraphChunkSource extends WithParameters( @@ -1851,7 +2413,7 @@ class GrapheneChunkedGraphChunkSource extends WithParameters( ChunkedGraphChunkSource, ), ChunkedGraphSourceParameters, -) {} +) { } type ChunkedGraphLayerDisplayState = SegmentationDisplayState3D; @@ -1898,7 +2460,7 @@ class SliceViewPanelChunkedGraphLayer extends SliceViewPanelRenderLayer { ); const sharedObject = (this.sharedObject = - this.backend = + this.backend = this.registerDisposer( new SegmentationLayerSharedObject( chunkManager, @@ -1993,6 +2555,7 @@ class SliceViewPanelChunkedGraphLayer extends SliceViewPanelRenderLayer { const GRAPHENE_MULTICUT_SEGMENTS_TOOL_ID = "grapheneMulticutSegments"; const GRAPHENE_MERGE_SEGMENTS_TOOL_ID = "grapheneMergeSegments"; +const GRAPHENE_FIND_PATH_TOOL_ID = "grapheneFindPath"; class MulticutAnnotationLayerView extends AnnotationLayerView { private _annotationStates: MergedAnnotationStates; @@ -2023,51 +2586,50 @@ class MulticutAnnotationLayerView extends AnnotationLayerView { } } +const addSelection = ( + source: AnnotationSource | MultiscaleAnnotationSource, + selection: SegmentSelection, + description?: string, +) => { + const annotation: Point = { + id: "", + point: selection.position, + type: AnnotationType.POINT, + properties: [], + relatedSegments: [[selection.segmentId, selection.rootId]], + description, + }; + const ref = source.add(annotation); + selection.annotationReference = ref; +}; + const synchronizeAnnotationSource = ( source: WatchableSet, state: AnnotationLayerState, ) => { const annotationSource = state.source; - annotationSource.childDeleted.add((annotationId) => { const selection = [...source].find( (selection) => selection.annotationReference?.id === annotationId, ); if (selection) source.delete(selection); }); - - const addSelection = (selection: SegmentSelection) => { - const annotation: Point = { - id: "", - point: selection.position, - type: AnnotationType.POINT, - properties: [], - relatedSegments: [[selection.segmentId, selection.rootId]], - }; - const ref = annotationSource.add(annotation); - selection.annotationReference = ref; - }; - source.changed.add((x, add) => { if (x === null) { for (const annotation of annotationSource) { - // using .clear does not remove annotations from the list - // (this.blueGroupAnnotationState.source as LocalAnnotationSource).clear(); annotationSource.delete(annotationSource.getReference(annotation.id)); } return; } - if (add) { - addSelection(x); + addSelection(annotationSource, x); } else if (x.annotationReference) { annotationSource.delete(x.annotationReference); } }); - // load initial state for (const selection of source) { - addSelection(selection); + addSelection(annotationSource, selection); } }; @@ -2111,6 +2673,122 @@ const getPoint = ( return undefined; }; +const GRAPHENE_TIME_JSON_KEY = "grapheneTime"; + +const timeControl = { + label: "Time", + title: "View segmentation at earlier point of time", + toolJson: GRAPHENE_TIME_JSON_KEY, + ...timeLayerControl(), +}; + +registerLayerControl(SegmentationUserLayer, timeControl); + +function timeLayerControl(): LayerControlFactory { + return { + makeControl: (layer, context) => { + const segmentationGroupState = + layer.displayState.segmentationGroupState.value; + const { + graph: { value: graph }, + } = segmentationGroupState; + const timestamp = + graph instanceof GrapheneGraphSource + ? segmentationGroupState.timestamp + : new WatchableValue(undefined); + const timestampLimit = + graph instanceof GrapheneGraphSource + ? graph.timestampLimit + : new WatchableValue(0); + const timestampOwner = + graph instanceof GrapheneGraphSource + ? segmentationGroupState.timestampOwner + : new WatchableSet(); + + const controlElement = document.createElement("div"); + controlElement.classList.add("neuroglancer-time-control"); + const intermediateTimestamp = new WatchableValue( + timestamp.value, + ); + intermediateTimestamp.changed.add(async () => { + if (intermediateTimestamp.value === timestamp.value) { + return; + } + // resetting timestamp back to unset + if ( + intermediateTimestamp.value === undefined && + segmentationGroupState.canSetTimestamp(layer.managedLayer.name) + ) { + timestamp.value = intermediateTimestamp.value; + timestampOwner.delete(layer.managedLayer.name); + return; + } + if (graph instanceof GrapheneGraphSource) { + const selfLock = segmentationGroupState.timestampOwner.has( + layer.managedLayer.name, + ); + const canSetTimestamp = segmentationGroupState.canSetTimestamp( + layer.managedLayer.name, + ); + // if we have a lock while the timestamp is unset, it is a tool-based lock (this check can be improved) + if (canSetTimestamp && (!selfLock || timestamp.value !== undefined)) { + const nonLatestRoots = await graph.graphServer.filterLatestRoots( + [...segmentationGroupState.selectedSegments], + timestamp.value, + true, + ); + if ( + !nonLatestRoots.length || + confirm( + `Changing graphene time will clear ${nonLatestRoots.length} segment(s).`, + ) + ) { + timestamp.value = intermediateTimestamp.value; + // is this where it is done + timestampOwner.add(layer.managedLayer.name); + return; + } + } + intermediateTimestamp.value = timestamp.value; + StatusMessage.showTemporaryMessage("Timestamp is locked."); + } + }); + const widget = context.registerDisposer( + new DateTimeInputWidget( + intermediateTimestamp, + new Date(timestampLimit.value), + new Date(), + ), + ); + timestampLimit.changed.add(() => { + widget.setMin(new Date(timestampLimit.value)); + }); + timestamp.changed.add(() => { + if (timestamp.value !== intermediateTimestamp.value) { + intermediateTimestamp.value = timestamp.value; + } + }); + controlElement.appendChild(widget.element); + return { controlElement, control: widget }; + }, + activateTool: (_activation) => { }, + }; +} + +const checkSegmentationOld = ( + timestamp: WatchableValue, + activation: ToolActivation, +) => { + if (timestamp.value !== undefined) { + StatusMessage.showTemporaryMessage( + "Editing can not be performed with a segmentation at an older state.", + ); + activation.cancel(); + return true; + } + return false; +}; + const MULTICUT_SEGMENTS_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift?+control+mousedown0": { action: "set-anchor" }, "at:shift?+keyg": { action: "swap-group" }, @@ -2138,7 +2816,7 @@ class MulticutSegmentsTool extends LayerTool { const { body, header } = makeToolActivationStatusMessageWithHeader(activation); header.textContent = "Multicut segments"; - body.classList.add("graphene-multicut-status"); + body.classList.add("graphene-tool-status", "graphene-multicut"); body.appendChild( makeIcon({ text: "Swap", @@ -2190,12 +2868,14 @@ class MulticutSegmentsTool extends LayerTool { const priorBaseSegmentHighlighting = displayState.baseSegmentHighlighting.value; const priorHighlightColor = displayState.highlightColor.value; + const priorHideSegmentZero = displayState.hideSegmentZero.value; activation.bindInputEventMap(MULTICUT_SEGMENTS_INPUT_EVENT_MAP); activation.registerDisposer(() => { resetMulticutDisplay(); displayState.baseSegmentHighlighting.value = priorBaseSegmentHighlighting; displayState.highlightColor.value = priorHighlightColor; + displayState.hideSegmentZero.value = priorHideSegmentZero; }); const resetMulticutDisplay = () => { resetTemporaryVisibleSegmentsState(segmentationGroupState); @@ -2216,6 +2896,7 @@ class MulticutSegmentsTool extends LayerTool { displayState.highlightColor.value = multicutState.blueGroup.value ? BLUE_COLOR_HIGHTLIGHT : RED_COLOR_HIGHLIGHT; + displayState.hideSegmentZero.value = false; segmentsState.useTemporaryVisibleSegments.value = true; segmentsState.useTemporarySegmentEquivalences.value = true; // add focus segment and red/blue segments @@ -2422,9 +3103,7 @@ function mergeToLine(submission: MergeSubmission): Line { return res; } -const MAX_MERGE_COUNT = 10; - -// on error, copy (also clean up error message) +const MAX_MERGE_COUNT = 20; const MERGE_SEGMENTS_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift?+enter": { action: "submit" }, @@ -2436,17 +3115,22 @@ class MergeSegmentsTool extends LayerTool { graphConnection: { value: graphConnection }, tool, } = this.layer; - if (!graphConnection || !(graphConnection instanceof GraphConnection)) + if (!graphConnection || !(graphConnection instanceof GraphConnection)) { + activation.cancel(); return; + } const { state: { mergeState }, + segmentsState: { timestamp }, + mergeAnnotationState, } = graphConnection; - if (mergeState === undefined) return; + if (checkSegmentationOld(timestamp, activation)) { + return; + } const { merges, autoSubmit } = mergeState; - const lineTool = new MergeSegmentsPlaceLineTool( this.layer, - graphConnection.mergeAnnotationState, + mergeAnnotationState, ); tool.value = lineTool; activation.registerDisposer(() => { @@ -2455,7 +3139,7 @@ class MergeSegmentsTool extends LayerTool { const { body, header } = makeToolActivationStatusMessageWithHeader(activation); header.textContent = "Merge segments"; - body.classList.add("graphene-merge-segments-status"); + body.classList.add("graphene-tool-status", "graphene-merge-segments"); activation.bindInputEventMap(MERGE_SEGMENTS_INPUT_EVENT_MAP); const submitAction = async () => { if (merges.value.filter((x) => x.locked).length) return; @@ -2570,6 +3254,143 @@ class MergeSegmentsTool extends LayerTool { } } +const FIND_PATH_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:shift?+enter": { action: "submit" }, + "at:shift?+control+mousedown0": { action: "add-point" }, +}); + +class FindPathTool extends LayerTool { + activate(activation: ToolActivation) { + const { layer } = this; + const { + graphConnection: { value: graphConnection }, + } = layer; + if (!graphConnection || !(graphConnection instanceof GraphConnection)) + return; + const { + state: { findPathState }, + findPathAnnotationState, + } = graphConnection; + const { source, target, precisionMode } = findPathState; + // Ensure we use the same segmentationGroupState while activated. + const segmentationGroupState = + this.layer.displayState.segmentationGroupState.value; + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Find Path"; + body.classList.add("graphene-tool-status", "graphene-find-path"); + const submitAction = () => { + findPathState.triggerPathUpdate.dispatch(); + }; + body.appendChild( + makeIcon({ + text: "Submit", + title: "Submit Find Path", + onClick: () => { + submitAction(); + }, + }), + ); + body.appendChild( + makeIcon({ + text: "Clear", + title: "Clear Find Path", + onClick: () => { + findPathState.source.reset(); + findPathState.target.reset(); + findPathState.centroids.reset(); + }, + }), + ); + const checkbox = activation.registerDisposer( + new TrackableBooleanCheckbox(precisionMode), + ); + const label = document.createElement("label"); + const labelText = document.createElement("span"); + labelText.textContent = "Precision mode: "; + label.appendChild(labelText); + label.title = + "Precision mode returns a more accurate path, but takes longer."; + label.appendChild(checkbox.element); + body.appendChild(label); + const annotationElements = document.createElement("div"); + annotationElements.classList.add("find-path-annotations"); + body.appendChild(annotationElements); + const bindings = getDefaultAnnotationListBindings(); + this.registerDisposer(new MouseEventBinder(annotationElements, bindings)); + const updateAnnotationElements = () => { + removeChildren(annotationElements); + const maxColumnWidths = [0, 0, 0]; + const globalDimensionIndices = [0, 1, 2]; + const localDimensionIndices: number[] = []; + const template = + "[symbol] 2ch [dim] var(--neuroglancer-column-0-width) [dim] var(--neuroglancer-column-1-width) [dim] var(--neuroglancer-column-2-width) [delete] min-content"; + const endpoints = [source, target]; + const endpointAnnotations = endpoints + .map((x) => x.value?.annotationReference?.value) + .filter((x) => x) as Annotation[]; + for (const annotation of endpointAnnotations) { + const [element, elementColumnWidths] = makeAnnotationListElement( + this.layer, + annotation, + findPathAnnotationState, + template, + globalDimensionIndices, + localDimensionIndices, + ); + for (const [column, width] of elementColumnWidths.entries()) { + maxColumnWidths[column] = width; + } + annotationElements.appendChild(element); + } + for (const [column, width] of maxColumnWidths.entries()) { + annotationElements.style.setProperty( + `--neuroglancer-column-${column}-width`, + `${width + 2}ch`, + ); + } + }; + findPathState.changed.add(updateAnnotationElements); + updateAnnotationElements(); + activation.bindInputEventMap(FIND_PATH_INPUT_EVENT_MAP); + activation.bindAction("submit", (event) => { + event.stopPropagation(); + submitAction(); + }); + activation.bindAction("add-point", (event) => { + event.stopPropagation(); + (async () => { + if (!source.value) { + // first selection + const selection = maybeGetSelection( + this, + segmentationGroupState.visibleSegments, + ); + if (selection) { + source.value = selection; + } + } else if (!target.value) { + const selection = maybeGetSelection( + this, + segmentationGroupState.visibleSegments, + ); + if (selection) { + target.value = selection; + } + } + })(); + }); + } + + toJSON() { + return GRAPHENE_FIND_PATH_TOOL_ID; + } + + get description() { + return "find path"; + } +} + registerTool( SegmentationUserLayer, GRAPHENE_MULTICUT_SEGMENTS_TOOL_ID, @@ -2586,6 +3407,10 @@ registerTool( }, ); +registerTool(SegmentationUserLayer, GRAPHENE_FIND_PATH_TOOL_ID, (layer) => { + return new FindPathTool(layer, true); +}); + const ANNOTATE_MERGE_LINE_TOOL_ID = "annotateMergeLine"; registerLegacyTool( diff --git a/src/datasource/graphene/graphene.css b/src/datasource/graphene/graphene.css index 7de9f02ad..969638532 100644 --- a/src/datasource/graphene/graphene.css +++ b/src/datasource/graphene/graphene.css @@ -14,12 +14,12 @@ color: #4444ff; } -.graphene-multicut-status { +.graphene-tool-status { display: flex; - flex-direction: row; + gap: 10px; } -.graphene-multicut-status > .activeGroupIndicator { +.graphene-multicut > .activeGroupIndicator { padding: 2px; margin: auto 0; background-color: red; @@ -27,15 +27,15 @@ font-weight: 900; } -.graphene-multicut-status > .activeGroupIndicator.blueGroup { +.graphene-multicut > .activeGroupIndicator.blueGroup { background-color: blue; } -.graphene-multicut-status > .activeGroupIndicator::after { +.graphene-multicut > .activeGroupIndicator::after { content: "Red"; } -.graphene-multicut-status > .activeGroupIndicator.blueGroup::after { +.graphene-multicut > .activeGroupIndicator.blueGroup::after { content: "Blue"; } @@ -49,13 +49,7 @@ gap: 10px; } -.graphene-merge-segments-status { - display: flex; - gap: 10px; -} - -.graphene-merge-segments-status .neuroglancer-icon, -.graphene-multicut-status .neuroglancer-icon { +.graphene-tool-status .neuroglancer-icon { height: 100%; } @@ -78,3 +72,22 @@ .graphene-merge-segments-point .neuroglancer-segment-list-entry-copy-container { display: none; } + +.graphene-find-path > label { + display: flex; +} + +.graphene-find-path > label > span { + display: flex; + align-content: center; + flex-wrap: wrap; +} + +.find-path-annotations { + display: flex; + gap: 10px; +} + +.find-path-annotations > .neuroglancer-annotation-list-entry { + background-color: black; +} diff --git a/src/datasource/middleauth/credentials_provider.ts b/src/datasource/middleauth/credentials_provider.ts index c5a6ab999..59328f015 100644 --- a/src/datasource/middleauth/credentials_provider.ts +++ b/src/datasource/middleauth/credentials_provider.ts @@ -22,6 +22,7 @@ import { CredentialsProvider, makeCredentialsGetter, } from "#src/credentials_provider/index.js"; +import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; import { StatusMessage } from "#src/status.js"; import { verifyObject, @@ -29,6 +30,7 @@ import { verifyString, verifyStringArray, } from "#src/util/json.js"; +import type { HttpError } from "src/util/http_request"; export type MiddleAuthToken = { tokenType: string; @@ -51,79 +53,49 @@ function openPopupCenter(url: string, width: number, height: number) { ); } -async function waitForLogin(serverUrl: string): Promise { +async function waitForRemoteFlow( + url: string, + startMessage: string, + startAction: string, + retryMessage: string, + closedMessage: string, +): Promise { const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true); - const res: Promise = new Promise((f) => { - function writeLoginStatus(message: string, buttonMessage: string) { + function writeStatus(message: string, buttonMessage: string) { status.element.textContent = message + " "; const button = document.createElement("button"); button.textContent = buttonMessage; status.element.appendChild(button); button.addEventListener("click", () => { - writeLoginStatus( - `Waiting for login to middleauth server ${serverUrl}...`, - "Retry", - ); - - const auth_popup = openPopupCenter( - `${serverUrl}/api/v1/authorize`, - 400, - 650, - ); - - const closeAuthPopup = () => { - auth_popup?.close(); + writeStatus(retryMessage, "Retry"); + const popup = openPopupCenter(url, 400, 650); + const closePopup = () => { + popup?.close(); }; - - window.addEventListener("beforeunload", closeAuthPopup); + window.addEventListener("beforeunload", closePopup); const checkClosed = setInterval(() => { - if (auth_popup?.closed) { + if (popup?.closed) { clearInterval(checkClosed); - writeLoginStatus( - `Login window closed for middleauth server ${serverUrl}.`, - "Retry", - ); + writeStatus(closedMessage, "Retry"); } }, 1000); - const tokenListener = async (ev: MessageEvent) => { - if (ev.source === auth_popup) { + const messageListener = async (ev: MessageEvent) => { + if (ev.source === popup) { clearInterval(checkClosed); - window.removeEventListener("message", tokenListener); - window.removeEventListener("beforeunload", closeAuthPopup); - closeAuthPopup(); - - verifyObject(ev.data); - const accessToken = verifyObjectProperty( - ev.data, - "token", - verifyString, - ); - const appUrls = verifyObjectProperty( - ev.data, - "app_urls", - verifyStringArray, - ); - - const token: MiddleAuthToken = { - tokenType: "Bearer", - accessToken, - url: serverUrl, - appUrls, - }; - f(token); + window.removeEventListener("message", messageListener); + window.removeEventListener("beforeunload", closePopup); + closePopup(); + f(ev.data); } }; - - window.addEventListener("message", tokenListener); + window.addEventListener("message", messageListener); }); } - - writeLoginStatus(`middleauth server ${serverUrl} login required.`, "Login"); + writeStatus(startMessage, startAction); }); - try { return await res; } finally { @@ -131,6 +103,37 @@ async function waitForLogin(serverUrl: string): Promise { } } +async function waitForLogin(serverUrl: string): Promise { + const data = await waitForRemoteFlow( + `${serverUrl}/api/v1/authorize`, + `middleauth server ${serverUrl} login required.`, + "Login", + `Waiting for login to middleauth server ${serverUrl}...`, + `Login window closed for middleauth server ${serverUrl}.`, + ); + verifyObject(data); + const accessToken = verifyObjectProperty(data, "token", verifyString); + const appUrls = verifyObjectProperty(data, "app_urls", verifyStringArray); + const token: MiddleAuthToken = { + tokenType: "Bearer", + accessToken, + url: serverUrl, + appUrls, + }; + return token; +} + +async function showTosForm(url: string, tosName: string) { + const data = await waitForRemoteFlow( + url, + `Before you can access ${tosName}, you need to accept its Terms of Service.`, + "Open", + "Waiting for Terms of Service agreement...", + `Terms of Service closed for ${tosName}.`, + ); + return data === "success"; +} + const LOCAL_STORAGE_AUTH_KEY = "auth_token_v2"; function getAuthTokenFromLocalStorage(authURL: string) { @@ -156,17 +159,14 @@ export class MiddleAuthCredentialsProvider extends CredentialsProvider { let token = undefined; - if (!this.alreadyTriedLocalStorage) { this.alreadyTriedLocalStorage = true; token = getAuthTokenFromLocalStorage(this.serverUrl); } - if (!token) { token = await waitForLogin(this.serverUrl); saveAuthTokenToLocalStorage(this.serverUrl, token); } - return token; }); } @@ -183,6 +183,7 @@ export class UnverifiedApp extends Error { export class MiddleAuthAppCredentialsProvider extends CredentialsProvider { private credentials: CredentialsWithGeneration | undefined = undefined; + agreedToTos = false; constructor( private serverUrl: string, @@ -192,6 +193,10 @@ export class MiddleAuthAppCredentialsProvider extends CredentialsProvider { + if (this.credentials && this.agreedToTos) { + return this.credentials.credentials; + } + this.agreedToTos = false; const authInfo = await fetch(`${this.serverUrl}/auth_info`).then((res) => res.json(), ); @@ -199,9 +204,7 @@ export class MiddleAuthAppCredentialsProvider extends CredentialsProvider => { + const { status } = error; + if (status === 401) { + // 401: Authorization needed. OAuth2 token may have expired. + return "refresh"; + } + if (status === 403) { + const { response } = error; + if (response) { + const { headers } = response; + const contentType = headers.get("content-type"); + if (contentType === "application/json") { + const json = await response.json(); + if (json.error && json.error === "missing_tos") { + // Missing terms of service agreement. Prompt user. + const url = new URL(json.data.tos_form_url); + url.searchParams.set("client", "ng"); + const success = await showTosForm( + url.toString(), + json.data.tos_name, + ); + if (success) { + this.agreedToTos = true; + return "refresh"; + } + } + } + } + if (!credentials.accessToken) { + // Anonymous access denied. Request credentials. + return "refresh"; + } + } + throw error; + }; } diff --git a/src/datasource/precomputed/frontend.ts b/src/datasource/precomputed/frontend.ts index 3ee7808d4..4e7563557 100644 --- a/src/datasource/precomputed/frontend.ts +++ b/src/datasource/precomputed/frontend.ts @@ -339,6 +339,7 @@ export class PrecomputedMultiscaleVolumeChunkSource extends MultiscaleVolumeChun return transposeNestedArrays( this.info.scales .filter((x) => !x.hidden) + .filter((x) => x.key !== "placeholder") .map((scaleInfo) => { const { resolution } = scaleInfo; const stride = rank + 1; diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 35c1a4e43..d984e2859 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -19,10 +19,15 @@ import "#src/layer/annotation/style.css"; import type { AnnotationDisplayState } from "#src/annotation/annotation_layer_state.js"; import { AnnotationLayerState } from "#src/annotation/annotation_layer_state.js"; import { MultiscaleAnnotationSource } from "#src/annotation/frontend_source.js"; -import type { AnnotationPropertySpec } from "#src/annotation/index.js"; +import type { + Annotation, + AnnotationPropertySpec, + AnnotationSource, +} from "#src/annotation/index.js"; import { annotationPropertySpecsToJson, AnnotationType, + isAnnotationTagPropertySpec, LocalAnnotationSource, parseAnnotationPropertySpecs, } from "#src/annotation/index.js"; @@ -45,12 +50,24 @@ import { RenderLayerRole } from "#src/renderlayer.js"; import type { SegmentationDisplayState } from "#src/segmentation_display_state/frontend.js"; import type { TrackableBoolean } from "#src/trackable_boolean.js"; import { TrackableBooleanCheckbox } from "#src/trackable_boolean.js"; -import { makeCachedLazyDerivedWatchableValue } from "#src/trackable_value.js"; +import { + ComputedWatchableValue, + makeCachedLazyDerivedWatchableValue, + WatchableValue, +} from "#src/trackable_value.js"; import type { AnnotationLayerView, MergedAnnotationStates, } from "#src/ui/annotations.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; +import { MessagesView } from "#src/ui/layer_data_sources_tab.js"; +import type { ToolActivation } from "#src/ui/tool.js"; +import { + LayerTool, + makeToolButton, + registerTool, + unregisterTool, +} from "#src/ui/tool.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -66,7 +83,10 @@ import { verifyString, verifyStringArray, } from "#src/util/json.js"; +import { MessageList, MessageSeverity } from "#src/util/message_list.js"; import { NullarySignal } from "#src/util/signal.js"; +import { makeAddButton } from "#src/widget/add_button.js"; +import { makeDeleteButton } from "#src/widget/delete_button.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { makeHelpButton } from "#src/widget/help_button.js"; import { LayerReferenceWidget } from "#src/widget/layer_reference.js"; @@ -78,6 +98,8 @@ import { ShaderControls, } from "#src/widget/shader_controls.js"; import { Tab } from "#src/widget/tab_view.js"; +import type { VirtualListSource } from "#src/widget/virtual_list.js"; +import { VirtualList } from "#src/widget/virtual_list.js"; const POINTS_JSON_KEY = "points"; const ANNOTATIONS_JSON_KEY = "annotations"; @@ -138,6 +160,7 @@ interface LinkedSegmentationLayer { const LINKED_SEGMENTATION_LAYER_JSON_KEY = "linkedSegmentationLayer"; const FILTER_BY_SEGMENTATION_JSON_KEY = "filterBySegmentation"; const IGNORE_NULL_SEGMENT_FILTER_JSON_KEY = "ignoreNullSegmentFilter"; +const SWAP_VISIBLE_SEGMENTS_ON_MOVE_JSON_KEY = "swapVisbleSegmentsOnMove"; class LinkedSegmentationLayers extends RefCounted { changed = new NullarySignal(); @@ -379,13 +402,281 @@ class LinkedSegmentationLayersWidget extends RefCounted { } } +function getSelectedAnnotation(layer: AnnotationUserLayer) { + const { localAnnotations } = layer; + if (localAnnotations) { + const ourSelectionState = + layer.manager.root.selectionState.value?.layers.find( + (x) => x.layer === layer, + ); + if (ourSelectionState && ourSelectionState.state.annotationId) { + return localAnnotations.get(ourSelectionState.state.annotationId); + } + } + return undefined; +} + +function triggerAnnotationUpdate( + source: AnnotationSource, + annotation: Annotation, +) { + const reference = source.getReference(annotation.id); + source.update(reference, annotation); + source.commit(reference); // commmit may not be necessary +} + +class TagTool extends LayerTool { + static TOOL_ID = "tagTool"; + + constructor( + public propertyIdentifier: string, + layer: AnnotationUserLayer, + ) { + super(layer, true); + } + + get tag(): string { + const { localAnnotations } = this.layer; + if (localAnnotations) { + const property = localAnnotations.properties.value.find( + (x) => x.identifier === this.propertyIdentifier, + ); + if (property && isAnnotationTagPropertySpec(property)) { + return property.tag; + } + } + return "unknown"; + } + + activate(activation: ToolActivation) { + const { localAnnotations } = this.layer; + if (!localAnnotations) return; + const annotation = getSelectedAnnotation(this.layer); + if (annotation) { + const { propertyIdentifier } = this; + const propertyIndex = localAnnotations.properties.value.findIndex( + (x) => x.identifier === propertyIdentifier, + ); + if (propertyIndex > -1) { + annotation.properties[propertyIndex] = + 1 - annotation.properties[propertyIndex]; + triggerAnnotationUpdate(localAnnotations, annotation); + } + } + activation.cancel(); + } + + toJSON() { + return `${TagTool.TOOL_ID}_${this.propertyIdentifier}`; + } + + get description() { + // currently this updates correctly because property changes trigger layer changes + // which triggers tool widgets to be recreated when the layer is active + return `tag ${this.tag}`; + } +} + +class TagsTab extends Tab { + tools = new Set(); + + constructor(public layer: Borrowed) { + super(); + const { element } = this; + element.classList.add("neuroglancer-tags-tab"); + const { localAnnotations } = layer; + if (!localAnnotations) return; + const { properties } = localAnnotations; + const tagsContainer = document.createElement("div"); + tagsContainer.classList.add("neuroglancer-tags-container"); + element.appendChild(tagsContainer); + + let previousListLength = 0; + + let prevList: string[] = []; + const messages = new MessageList(); + + const validateNewTag = (tag: string) => { + messages.clearMessages(); + if (prevList.includes(tag)) { + messages.addMessage({ + severity: MessageSeverity.error, + message: `tag: "${tag}" already exists`, + }); + return false; + } + return true; + }; + + const getUniqueTagPropertyId = (source: AnnotationSource) => { + const { properties } = source; + let largestTagId = -1; + for (const p of properties.value) { + const res = p.identifier.match(/tag([\d]+)/); + largestTagId++; + if (res && res.length > 1) { + largestTagId = parseInt(res[1]); + } + } + return `tag${largestTagId + 1}`; + }; + + const addTag = (input: HTMLInputElement) => { + const { value } = input; + if (input.validity.valid) { + if (validateNewTag(value)) { + localAnnotations.addProperty({ + type: "uint8", + tag: value, + default: 0, + description: undefined, + identifier: getUniqueTagPropertyId(localAnnotations), + }); + } + } + }; + + const listSource: VirtualListSource = { + length: 1, + render: (index: number) => { + const el = document.createElement("div"); + el.classList.add("neuroglancer-tag-list-entry"); + const inputElement = document.createElement("input"); + inputElement.required = true; + el.append(inputElement); + if (index === listSource.length - 1) { + // add new tag UI + el.classList.add("add"); + // this is created just to match the width of the tool button + const tool = makeToolButton(this, layer.toolBinder, { + toolJson: `${TagTool.TOOL_ID}_${"_invalid"}`, + }); + el.prepend(tool); + inputElement.placeholder = "Tag name"; + // select input when number of tags increases, this is useful for adding multiple tags in a row + if (previousListLength < listSource.length) { + setTimeout(() => { + inputElement.focus(); + }, 0); + } + inputElement.addEventListener("keyup", (evt) => { + if (evt.key === "Enter") { + addTag(inputElement); + } + }); + const addNewTagButton = makeAddButton({ + title: "Add additional tag", + onClick: () => addTag(inputElement), + }); + el.append(addNewTagButton); + previousListLength = listSource.length; + } else { + const property = localAnnotations.getTagProperties()[index]; + const { tag } = property; + const tool = makeToolButton(this, layer.toolBinder, { + toolJson: `${TagTool.TOOL_ID}_${property.identifier}`, + title: `Tag selected annotation with ${tag}`, + }); + el.prepend(tool); + inputElement.value = tag; + inputElement.addEventListener("change", () => { + const { value } = inputElement; + if ( + !validateNewTag(value) || + !confirm(`Rename tag ${tag} to ${value}?`) + ) { + inputElement.value = tag; + return; + } + property.tag = value; + properties.changed.dispatch(); + + // is this a better way to update the selection? + const annotation = getSelectedAnnotation(this.layer); + if (annotation) { + triggerAnnotationUpdate(localAnnotations, annotation); + } + // or something like this? + // this.layer.manager.root.selectionState.changed.dispatch(); // TODO, this is probably not the best way to handle it + }); + const deleteButton = makeDeleteButton({ + title: "Delete tag", + onClick: (event) => { + event.stopPropagation(); + event.preventDefault(); + if (confirm(`Delete tag ${tag}?`)) { + localAnnotations.removeProperty(property.identifier); + } + // is this a better way to update the selection? + const annotation = getSelectedAnnotation(this.layer); + if (annotation) { + triggerAnnotationUpdate(localAnnotations, annotation); + } + // or something like this? + // this.layer.manager.root.selectionState.changed.dispatch(); // TODO, this is probably not the best way to handle it + }, + }); + deleteButton.classList.add("neuroglancer-tag-list-entry-delete"); + el.append(deleteButton); + } + return el; + }, + changed: new NullarySignal(), + }; + const list = this.registerDisposer( + new VirtualList({ + source: listSource, + }), + ); + tagsContainer.appendChild(list.element); + const messagesView = this.registerDisposer(new MessagesView(messages)); + tagsContainer.appendChild(messagesView.element); + list.body.classList.add("neuroglancer-tag-list"); + list.element.classList.add("neuroglancer-tag-list-outer"); + + const updateTagList = () => { + let retainCount = 1; // new entry + let deleteCount = 0; + let insertCount = 0; + const newList = localAnnotations.getTagProperties().map((x) => x.tag); + for (const tag of newList) { + if (prevList.includes(tag)) { + retainCount++; + } else { + insertCount++; + } + } + for (const tag of prevList) { + if (!newList.includes(tag)) { + deleteCount++; + } + } + listSource.length = newList.length + 1; + prevList = newList; + if (deleteCount > 0 || insertCount > 0) { + listSource.changed!.dispatch([ + { + retainCount, + deleteCount, + insertCount, + }, + ]); + } + }; + this.registerDisposer(properties.changed.add(updateTagList)); + updateTagList(); + } +} + const Base = UserLayerWithAnnotationsMixin(UserLayer); export class AnnotationUserLayer extends Base { localAnnotations: LocalAnnotationSource | undefined; - private localAnnotationProperties: AnnotationPropertySpec[] | undefined; + private localAnnotationProperties: WatchableValue = + new WatchableValue([]); private localAnnotationRelationships: string[]; private localAnnotationsJson: any = undefined; private pointAnnotationsJson: any = undefined; + private tagTools: string[] = []; linkedSegmentationLayers = this.registerDisposer( new LinkedSegmentationLayers( this.manager.rootLayers, @@ -416,23 +707,88 @@ export class AnnotationUserLayer extends Base { this.annotationProjectionRenderScaleTarget.changed.add( this.specificationChanged.dispatch, ); + this.registerDisposer( + this.localAnnotationProperties.changed.add(() => { + const { localAnnotations } = this; + if (localAnnotations) { + const tagIdentifiers = localAnnotations + .getTagProperties() + .map((x) => x.identifier); + this.syncTagTools(tagIdentifiers); + } + }), + ); this.tabs.add("rendering", { label: "Rendering", order: -100, getter: () => new RenderingOptionsTab(this), }); + const hideTagsTab = this.registerDisposer( + new ComputedWatchableValue(() => { + return this.localAnnotations === undefined; + }, this.dataSourcesChanged), + ); + this.tabs.add("tags", { + label: "Tags", + order: 10, + getter: () => new TagsTab(this), + hidden: hideTagsTab, + }); this.tabs.default = "annotations"; } + syncTagTools = (tagIdentifiers: string[]) => { + // TODO, change to set? intersection etc + for (const propertyIdentifier of this.tagTools) { + if (!tagIdentifiers.includes(propertyIdentifier)) { + unregisterTool( + AnnotationUserLayer, + `${TagTool.TOOL_ID}_${propertyIdentifier}`, + ); + for (const [key, tool] of this.toolBinder.bindings.entries()) { + if ( + tool instanceof TagTool && + tool.propertyIdentifier === propertyIdentifier + ) { + this.toolBinder.deleteTool(key); + } + } + } + } + this.tagTools = this.tagTools.filter((x) => tagIdentifiers.includes(x)); + for (const tagIdentifier of tagIdentifiers) { + if (!this.tagTools.includes(tagIdentifier)) { + this.tagTools.push(tagIdentifier); + registerTool( + AnnotationUserLayer, + `${TagTool.TOOL_ID}_${tagIdentifier}`, + (layer) => { + const tool = new TagTool(tagIdentifier, layer); + return tool; + }, + ); + } + } + }; + restoreState(specification: any) { - super.restoreState(specification); - this.linkedSegmentationLayers.restoreState(specification); - this.localAnnotationsJson = specification[ANNOTATIONS_JSON_KEY]; - this.localAnnotationProperties = verifyOptionalObjectProperty( + // restore tag tools before super so tag tools are registered + const properties = verifyOptionalObjectProperty( specification, ANNOTATION_PROPERTIES_JSON_KEY, parseAnnotationPropertySpecs, ); + if (properties) { + this.syncTagTools( + properties.filter(isAnnotationTagPropertySpec).map((x) => x.identifier), + ); + } + super.restoreState(specification); + this.linkedSegmentationLayers.restoreState(specification); + this.localAnnotationsJson = specification[ANNOTATIONS_JSON_KEY]; + if (properties) { + this.localAnnotationProperties.value = properties || []; + } this.localAnnotationRelationships = verifyOptionalObjectProperty( specification, ANNOTATION_RELATIONSHIPS_JSON_KEY, @@ -449,6 +805,9 @@ export class AnnotationUserLayer extends Base { this.annotationDisplayState.ignoreNullSegmentFilter.restoreState( specification[IGNORE_NULL_SEGMENT_FILTER_JSON_KEY], ); + this.annotationDisplayState.swapVisibleSegmentsOnMove.restoreState( + specification[SWAP_VISIBLE_SEGMENTS_ON_MOVE_JSON_KEY], + ); this.annotationDisplayState.shader.restoreState( specification[SHADER_JSON_KEY], ); @@ -515,14 +874,21 @@ export class AnnotationUserLayer extends Base { activateDataSubsources(subsources: Iterable) { let hasLocalAnnotations = false; - let properties: AnnotationPropertySpec[] | undefined; + let properties: + | WatchableValue[]> + | undefined; for (const loadedSubsource of subsources) { const { subsourceEntry } = loadedSubsource; const { local } = subsourceEntry.subsource; - const setProperties = (newProperties: AnnotationPropertySpec[]) => { + const setProperties = ( + newProperties: WatchableValue< + readonly Readonly[] + >, + ) => { if ( properties !== undefined && - stableStringify(newProperties) !== stableStringify(properties) + stableStringify(newProperties.value) !== + stableStringify(properties.value) ) { loadedSubsource.deactivate( "Annotation properties are not compatible", @@ -540,12 +906,12 @@ export class AnnotationUserLayer extends Base { continue; } hasLocalAnnotations = true; - if (!setProperties(this.localAnnotationProperties ?? [])) continue; + if (!setProperties(this.localAnnotationProperties)) continue; loadedSubsource.activate((refCounted) => { const localAnnotations = (this.localAnnotations = new LocalAnnotationSource( loadedSubsource.loadedDataSource.transform, - this.localAnnotationProperties ?? [], + this.localAnnotationProperties, this.localAnnotationRelationships, )); try { @@ -558,9 +924,9 @@ export class AnnotationUserLayer extends Base { this.localAnnotations = undefined; }); refCounted.registerDisposer( - this.localAnnotations.changed.add( - this.specificationChanged.dispatch, - ), + this.localAnnotations.changed.add(() => { + this.specificationChanged.dispatch(); + }), ); try { addPointAnnotations( @@ -613,12 +979,22 @@ export class AnnotationUserLayer extends Base { } loadedSubsource.deactivate("Not compatible with annotation layer"); } - const prevAnnotationProperties = - this.annotationDisplayState.annotationProperties.value; if ( - stableStringify(prevAnnotationProperties) !== stableStringify(properties) + properties && + stableStringify( + this.annotationDisplayState.annotationProperties.value, + ) !== stableStringify(properties?.value) ) { - this.annotationDisplayState.annotationProperties.value = properties; + this.registerDisposer( + properties.changed.add(() => { + this.annotationDisplayState.annotationProperties.value = [ + ...properties!.value, + ]; + }), + ); + this.annotationDisplayState.annotationProperties.value = [ + ...properties!.value, + ]; } } @@ -677,6 +1053,22 @@ export class AnnotationUserLayer extends Base { label.appendChild(checkbox.element); tab.element.appendChild(label); } + { + const checkbox = tab.registerDisposer( + new TrackableBooleanCheckbox( + this.annotationDisplayState.swapVisibleSegmentsOnMove, + ), + ); + const label = document.createElement("label"); + label.appendChild( + document.createTextNode( + "Swap visible segments when moving to annotation", + ), + ); + label.title = "Swap visible segments when moving to annotation"; + label.appendChild(checkbox.element); + tab.element.appendChild(label); + } tab.element.appendChild( tab.registerDisposer( new LinkedSegmentationLayersWidget(this.linkedSegmentationLayers), @@ -696,7 +1088,7 @@ export class AnnotationUserLayer extends Base { x[ANNOTATIONS_JSON_KEY] = this.localAnnotationsJson; } x[ANNOTATION_PROPERTIES_JSON_KEY] = annotationPropertySpecsToJson( - this.localAnnotationProperties, + this.localAnnotationProperties.value, ); const { localAnnotationRelationships } = this; x[ANNOTATION_RELATIONSHIPS_JSON_KEY] = @@ -706,6 +1098,8 @@ export class AnnotationUserLayer extends Base { : localAnnotationRelationships; x[IGNORE_NULL_SEGMENT_FILTER_JSON_KEY] = this.annotationDisplayState.ignoreNullSegmentFilter.toJSON(); + x[SWAP_VISIBLE_SEGMENTS_ON_MOVE_JSON_KEY] = + this.annotationDisplayState.swapVisibleSegmentsOnMove.toJSON(); x[SHADER_JSON_KEY] = this.annotationDisplayState.shader.toJSON(); x[SHADER_CONTROLS_JSON_KEY] = this.annotationDisplayState.shaderControls.toJSON(); @@ -770,6 +1164,14 @@ class RenderingOptionsTab extends Tab { if (description !== undefined) { div.title = description; } + if (isAnnotationTagPropertySpec(property)) { + const tagElement = document.createElement("span"); + tagElement.classList.add( + "neuroglancer-annotation-tag-property-type", + ); + tagElement.textContent = `(${property.tag})`; + div.appendChild(tagElement); + } propertyList.appendChild(div); } }, diff --git a/src/layer/annotation/style.css b/src/layer/annotation/style.css index 1c9238ff8..b8ecdfdd7 100644 --- a/src/layer/annotation/style.css +++ b/src/layer/annotation/style.css @@ -49,3 +49,63 @@ content: "()"; color: #999; } + +.neuroglancer-annotation-tag-property-type { + color: #999; +} + +.neuroglancer-tag-list > div { + display: grid; +} + +.neuroglancer-add-tag-control > input, +.neuroglancer-tag-list-entry > input { + background-color: #151515; + color: white; + font-family: monospace; + font-size: medium; + border: 2px solid #333; + padding: 2px; + outline: 0px; +} + +/* copy of .neuroglancer-annotation-layer-view */ +/* layer/annotation/style.css vs src/ui/annotations.css */ +.neuroglancer-tags-container { + display: flex; + flex-direction: column; + flex: 1; + align-items: stretch; +} + +.neuroglancer-tag-list-outer { + position: relative; + margin: 0px; + padding: 0px; + margin-top: 2px; + overflow-y: auto; + height: 0px; + flex: 1; + flex-basis: 0px; + min-height: 0px; +} + +.neuroglancer-tag-list-entry { + display: grid; + grid-template-columns: min-content auto min-content; + align-items: center; + white-space: nowrap; + padding: 2px 20px 2px 0; +} + +.neuroglancer-tag-list-entry.add .neuroglancer-tool-button { + visibility: hidden; +} + +.neuroglancer-tag-list-entry:hover .neuroglancer-tag-list-entry-delete { + visibility: visible; +} + +.neuroglancer-tag-list-entry .neuroglancer-tag-list-entry-delete { + visibility: hidden; +} \ No newline at end of file diff --git a/src/layer/index.ts b/src/layer/index.ts index ff3f7d8d2..3bee68eb3 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -99,7 +99,7 @@ import { } from "#src/util/json.js"; import { MessageList } from "#src/util/message_list.js"; import type { AnyConstructor } from "#src/util/mixin.js"; -import { NullarySignal } from "#src/util/signal.js"; +import { NullarySignal, Signal } from "#src/util/signal.js"; import type { SignalBindingUpdater } from "#src/util/signal_binding_updater.js"; import { addSignalBinding, @@ -192,6 +192,32 @@ export class UserLayer extends RefCounted { messages = new MessageList(); + layerEventListeners = new Map(); + + dispatchLayerEvent(type: string) { + this.layerEventListeners.get(type)?.dispatch(); + } + + registerLayerEvent(type: string, handler: () => void) { + const { layerEventListeners } = this; + let existingSignal = layerEventListeners.get(type); + if (!existingSignal) { + existingSignal = new Signal(); + layerEventListeners.set(type, existingSignal); + } + const unregister = existingSignal.add(handler); + return () => { + const res = unregister(); + // TODO delete from layerEventListeners if no other handlers attached? currently Signal.handlers is private + /* + if (existingSignal.handlers.length === 0) { + layerEventListeners.delete(type); + } + */ + return res; + }; + } + initializeSelectionState(state: this["selectionState"]) { state.generation = -1; state.localPositionValid = false; diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 8a5981d87..175f83402 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -97,6 +97,7 @@ import { makeCachedLazyDerivedWatchableValue, registerNestedSync, TrackableValue, + WatchableSet, WatchableValue, } from "#src/trackable_value.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; @@ -123,7 +124,9 @@ import { verifyFiniteNonNegativeFloat, verifyObjectAsMap, verifyOptionalObjectProperty, + verifyPositiveInt, verifyString, + verifyStringArray, } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; import { Uint64 } from "#src/util/uint64.js"; @@ -133,8 +136,7 @@ import { registerLayerShaderControlsTool } from "#src/widget/shader_controls.js" export class SegmentationUserLayerGroupState extends RefCounted - implements SegmentationGroupState -{ + implements SegmentationGroupState { specificationChanged = new Signal(); constructor(public layer: SegmentationUserLayer) { super(); @@ -161,6 +163,9 @@ export class SegmentationUserLayerGroupState } } }); + + this.timestamp.changed.add(specificationChanged.dispatch); + this.timestampOwner.changed.add(specificationChanged.dispatch); } restoreState(specification: unknown) { @@ -202,6 +207,24 @@ export class SegmentationUserLayerGroupState json_keys.SEGMENT_QUERY_JSON_KEY, (value) => this.segmentQuery.restoreState(value), ); + verifyOptionalObjectProperty( + specification, + json_keys.TIMESTAMP_OWNER_JSON_KEY, + (value) => { + const owners = verifyStringArray(value); + this.timestampOwner.clear(); + for (const owner of owners) { + this.timestampOwner.add(owner); + } + }, + ); + verifyOptionalObjectProperty( + specification, + json_keys.TIMESTAMP_JSON_KEY, + (value) => { + this.timestamp.restoreState(value); + }, + ); } toJSON() { @@ -223,6 +246,10 @@ export class SegmentationUserLayerGroupState x[json_keys.EQUIVALENCES_JSON_KEY] = segmentEquivalences.toJSON(); } x[json_keys.SEGMENT_QUERY_JSON_KEY] = this.segmentQuery.toJSON(); + x[json_keys.TIMESTAMP_JSON_KEY] = this.timestamp.toJSON(); + if (this.timestampOwner.size > 0) { + x[json_keys.TIMESTAMP_OWNER_JSON_KEY] = [...this.timestampOwner]; + } return x; } @@ -232,9 +259,13 @@ export class SegmentationUserLayerGroupState this.selectedSegments.assignFrom(other.selectedSegments); this.visibleSegments.assignFrom(other.visibleSegments); this.segmentEquivalences.assignFrom(other.segmentEquivalences); + this.timestamp.value = other.timestamp.value; + this.timestampOwner.values = new Set(other.timestampOwner); // TODO this won't trigger changed properly } localGraph = new LocalSegmentationGraphSource(); + timestamp = new TrackableValue(undefined, (x) => x); + timestampOwner = new WatchableSet(); visibleSegments = this.registerDisposer( Uint64Set.makeWithCounterpart(this.layer.manager.rpc), ); @@ -277,12 +308,19 @@ export class SegmentationUserLayerGroupState useTemporarySegmentEquivalences = this.layer.registerDisposer( SharedWatchableValue.make(this.layer.manager.rpc, false), ); + + canSetTimestamp(owner?: string) { + const otherOwners = [...this.timestampOwner].filter((x) => x !== owner); + if (otherOwners.length) { + return false; + } + return true; + } } export class SegmentationUserLayerColorGroupState extends RefCounted - implements SegmentationColorGroupState -{ + implements SegmentationColorGroupState { specificationChanged = new Signal(); constructor(public layer: SegmentationUserLayer) { super(); @@ -356,10 +394,10 @@ export class SegmentationUserLayerColorGroupState } class LinkedSegmentationGroupState< - State extends - | SegmentationUserLayerGroupState - | SegmentationUserLayerColorGroupState, - > + State extends + | SegmentationUserLayerGroupState + | SegmentationUserLayerColorGroupState, +> extends RefCounted implements WatchableValueInterface { @@ -484,6 +522,7 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { ); objectAlpha = trackableAlphaValue(1.0); ignoreNullVisibleSet = new TrackableBoolean(true, true); + stopLayer = new TrackableValue(0, verifyPositiveInt); skeletonRenderingOptions = new SkeletonRenderingOptions(); shaderError = makeWatchableShaderError(); renderScaleHistogram = new RenderScaleHistogram(); @@ -594,7 +633,11 @@ export class SegmentationUserLayer extends Base { }; displayState = new SegmentationUserLayerDisplayState(this); - + stopLayer = new TrackableValue( + 0, + verifyFiniteNonNegativeFloat, + 0, + ); anchorSegment = new TrackableValue(undefined, (x) => x === undefined ? undefined : Uint64.parseString(x), ); @@ -786,7 +829,7 @@ export class SegmentationUserLayer extends Base { "Not supported on non-root linked segmentation layers", ); } else { - loadedSubsource.activate(() => {}); + loadedSubsource.activate(() => { }); updatedSegmentPropertyMaps.push(segmentPropertyMap); } } else if (segmentationGraph !== undefined) { @@ -913,7 +956,7 @@ export class SegmentationUserLayer extends Base { if ( layerSpec[json_keys.EQUIVALENCES_JSON_KEY] !== undefined && explicitSpecs.find((spec) => spec.url === localEquivalencesUrl) === - undefined + undefined ) { specs.push({ url: localEquivalencesUrl, diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index 96417a61c..9677ab85f 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -9,6 +9,7 @@ export const IGNORE_NULL_VISIBLE_SET_JSON_KEY = "ignoreNullVisibleSet"; export const MESH_JSON_KEY = "mesh"; export const SKELETONS_JSON_KEY = "skeletons"; export const SEGMENTS_JSON_KEY = "segments"; +export const STOP_LAYER = 'stop_layer'; export const EQUIVALENCES_JSON_KEY = "equivalences"; export const COLOR_SEED_JSON_KEY = "colorSeed"; export const SEGMENT_STATED_COLORS_JSON_KEY = "segmentColors"; @@ -25,3 +26,5 @@ export const SEGMENT_DEFAULT_COLOR_JSON_KEY = "segmentDefaultColor"; export const ANCHOR_SEGMENT_JSON_KEY = "anchorSegment"; export const SKELETON_RENDERING_SHADER_CONTROL_TOOL_ID = "skeletonShaderControl"; +export const TIMESTAMP_JSON_KEY = "timestamp"; +export const TIMESTAMP_OWNER_JSON_KEY = "timestampOwner"; diff --git a/src/layer/segmentation/style.css b/src/layer/segmentation/style.css index dd92be019..359f45ee7 100644 --- a/src/layer/segmentation/style.css +++ b/src/layer/segmentation/style.css @@ -136,3 +136,19 @@ + .neuroglancer-tool-button { margin-left: 1em; } + +.neuroglancer-segment-list-entry .neuroglancer-color-widget { + border: none; + border-color: transparent; + appearance: none; + background-color: transparent; + padding: 0; + margin: 0; + margin-left: 3px; + height: 19px; + width: 20px; +} + +.neuroglancer-segment-list-entry .neuroglancer-color-widget.overridden { + background-color: white; +} diff --git a/src/segmentation_display_state/backend.ts b/src/segmentation_display_state/backend.ts index b3f99edd9..7ce1348af 100644 --- a/src/segmentation_display_state/backend.ts +++ b/src/segmentation_display_state/backend.ts @@ -27,12 +27,13 @@ import type { VisibleSegmentsState, } from "#src/segmentation_display_state/base.js"; import { + VISIBLE_SEGMENTS_STATE_PROPERTIES, onTemporaryVisibleSegmentsStateChanged, onVisibleSegmentsStateChanged, - VISIBLE_SEGMENTS_STATE_PROPERTIES, } from "#src/segmentation_display_state/base.js"; import type { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { WatchableValue } from "#src/trackable_value.js"; import type { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import type { Uint64Set } from "#src/uint64_set.js"; import type { AnyConstructor } from "#src/util/mixin.js"; @@ -57,6 +58,7 @@ export const withSegmentationLayerBackendState = < Base: TBase, ) => class SegmentationLayerState extends Base implements VisibleSegmentsState { + timestamp: WatchableValue; visibleSegments: Uint64Set; selectedSegments: Uint64OrderedSet; segmentEquivalences: SharedDisjointUint64Sets; diff --git a/src/segmentation_display_state/base.ts b/src/segmentation_display_state/base.ts index 6b0971653..492b8da60 100644 --- a/src/segmentation_display_state/base.ts +++ b/src/segmentation_display_state/base.ts @@ -17,12 +17,14 @@ import { VisibleSegmentEquivalencePolicy } from "#src/segmentation_graph/segment_id.js"; import type { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { WatchableValue } from "#src/trackable_value.js"; import type { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import type { Uint64Set } from "#src/uint64_set.js"; import type { RefCounted } from "#src/util/disposable.js"; import type { Uint64 } from "#src/util/uint64.js"; export interface VisibleSegmentsState { + timestamp: WatchableValue; visibleSegments: Uint64Set; selectedSegments: Uint64OrderedSet; segmentEquivalences: SharedDisjointUint64Sets; diff --git a/src/segmentation_display_state/frontend.ts b/src/segmentation_display_state/frontend.ts index df36d1249..ab4f210ff 100644 --- a/src/segmentation_display_state/frontend.ts +++ b/src/segmentation_display_state/frontend.ts @@ -47,14 +47,19 @@ import { observeWatchable, registerNestedSync } from "#src/trackable_value.js"; import { isWithinSelectionPanel } from "#src/ui/selection_details.js"; import type { Uint64Map } from "#src/uint64_map.js"; import { setClipboard } from "#src/util/clipboard.js"; -import { useWhiteBackground } from "#src/util/color.js"; +import { + packColor, + serializeColor, + TrackableRGB, + useWhiteBackground, +} from "#src/util/color.js"; import { RefCounted } from "#src/util/disposable.js"; import { measureElementClone } from "#src/util/dom.js"; -import type { vec3 } from "#src/util/geom.js"; -import { kOneVec, vec4 } from "#src/util/geom.js"; +import { kOneVec, vec3, vec4 } from "#src/util/geom.js"; import { NullarySignal } from "#src/util/signal.js"; import { Uint64 } from "#src/util/uint64.js"; import { withSharedVisibility } from "#src/visibility_priority/frontend.js"; +import { ColorWidget } from "#src/widget/color.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import { makeEyeButton } from "#src/widget/eye_button.js"; import { makeFilterButton } from "#src/widget/filter_button.js"; @@ -347,6 +352,8 @@ const segmentWidgetTemplate = (() => { filterElement.classList.add("neuroglancer-segment-list-entry-filter"); const filterIndex = template.childElementCount; template.appendChild(filterElement); + const colorWidgetIndex = template.childElementCount; + template.appendChild(ColorWidget.template()); return { template, copyContainerIndex, @@ -357,6 +364,7 @@ const segmentWidgetTemplate = (() => { labelIndex, filterIndex, starIndex, + colorWidgetIndex, unmappedIdIndex: -1, unmappedCopyIndex: -1, }; @@ -426,7 +434,7 @@ function makeRegisterSegmentWidgetEventHandlers( const onMouseEnter = (event: Event) => { const entryElement = event.currentTarget as HTMLElement; const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); displayState.segmentSelectionState.set(id); if (!isWithinSelectionPanel(entryElement)) { @@ -437,7 +445,7 @@ function makeRegisterSegmentWidgetEventHandlers( const selectHandler = (event: Event) => { const entryElement = event.currentTarget as HTMLElement; const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); displayState.selectSegment( id, @@ -470,7 +478,7 @@ function makeRegisterSegmentWidgetEventHandlers( const visibleCheckboxHandler = (event: Event) => { const entryElement = getEntryElement(event); const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); const { selectedSegments, visibleSegments } = displayState.segmentationGroupState.value; @@ -486,7 +494,7 @@ function makeRegisterSegmentWidgetEventHandlers( const filterHandler = (event: Event) => { const entryElement = getEntryElement(event); const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); displayState.filterBySegmentLabel(id); event.stopPropagation(); @@ -504,7 +512,7 @@ function makeRegisterSegmentWidgetEventHandlers( } const entryElement = event.currentTarget as HTMLElement; const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); displayState.moveToSegment(id); }; @@ -539,11 +547,35 @@ function makeRegisterSegmentWidgetEventHandlers( starButton.addEventListener("click", (event: MouseEvent) => { const entryElement = getEntryElement(event); const idString = entryElement.dataset.id!; - const id = tempStatedColor; + const id = tempObjectId; id.tryParseString(idString); const { selectedSegments } = displayState.segmentationGroupState.value; selectedSegments.set(id, !selectedSegments.has(id)); }); + + const trackableRGB = new TrackableRGB(vec3.fromValues(0, 0, 0)); + trackableRGB.changed.add(() => { + const testU = new Uint64(packColor(trackableRGB.value)); + const idString = element.dataset.id!; + const id = tempObjectId; + id.tryParseString(idString); + displayState.segmentStatedColors.value.delete(id); + displayState.segmentStatedColors.value.set(id, testU); + }); + + // TODO, need to register disposer? + new ColorWidget( + trackableRGB, + undefined, + children[template.colorWidgetIndex] as HTMLInputElement, + () => { + const idString = element.dataset.id!; + const id = tempObjectId; + id.tryParseString(idString); + displayState.segmentStatedColors.value.delete(id); + }, + false, + ); }; } @@ -641,7 +673,7 @@ export class SegmentWidgetFactory