From 02e0a711fbc63e50ac9dc84085417c383969df6d Mon Sep 17 00:00:00 2001 From: Chris Jordan Date: Tue, 1 Oct 2024 16:00:22 -0400 Subject: [PATCH] added annotation tagging feature --- src/annotation/annotation_layer_state.ts | 2 +- src/annotation/frontend_source.ts | 9 +- src/annotation/index.ts | 93 ++++- src/annotation/renderlayer.ts | 18 +- src/datasource/graphene/frontend.ts | 2 +- src/layer/annotation/index.ts | 420 +++++++++++++++++++++-- src/layer/annotation/style.css | 60 ++++ src/ui/annotations.css | 9 +- src/ui/annotations.ts | 91 +++-- src/ui/tool.ts | 30 +- 10 files changed, 654 insertions(+), 80 deletions(-) diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index c9da60d6b..d2c0aa56a 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( 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/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index 04a1281b6..d2924f5f5 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -763,7 +763,7 @@ function makeColoredAnnotationState( const { subsourceEntry } = loadedSubsource; const source = new LocalAnnotationSource( loadedSubsource.loadedDataSource.transform, - [], + new WatchableValue([]), ["associated segments"], ); diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 35c1a4e43..e578d1691 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"; @@ -379,13 +401,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 +706,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, @@ -515,14 +870,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 +902,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 +920,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 +975,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, + ]; } } @@ -696,7 +1068,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] = @@ -770,6 +1142,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/ui/annotations.css b/src/ui/annotations.css index 39b3b41bf..da60e397d 100644 --- a/src/ui/annotations.css +++ b/src/ui/annotations.css @@ -14,7 +14,7 @@ * limitations under the License. */ -.neuroglancer-annotations-tab { +.neuroglancer-annotations-tab, .neuroglancer-annotation-layer-view, .neuroglancer-tags-tab { display: flex; align-items: stretch; flex: 1; @@ -44,13 +44,6 @@ display: contents; } -.neuroglancer-annotation-layer-view { - display: flex; - flex-direction: column; - flex: 1; - align-items: stretch; -} - .neuroglancer-annotation-list-header { grid-auto-rows: min-content; display: grid; diff --git a/src/ui/annotations.ts b/src/ui/annotations.ts index 871528ea8..b68dd487b 100644 --- a/src/ui/annotations.ts +++ b/src/ui/annotations.ts @@ -39,6 +39,7 @@ import { AnnotationType, annotationTypeHandlers, formatNumericProperty, + isAnnotationTagPropertySpec, } from "#src/annotation/index.js"; import { AnnotationLayer, @@ -196,6 +197,26 @@ function setLayerPosition( layer.setLayerPosition(chunkTransform.modelTransform, layerPosition); } +const moveToAnnotation = ( + layer: UserLayer, + annotation: Annotation, + state: AnnotationLayerState, +) => { + const chunkTransform = state.chunkTransform.value as ChunkTransformParameters; + const { layerRank } = chunkTransform; + const chunkPosition = new Float32Array(layerRank); + const layerPosition = new Float32Array(layerRank); + getCenterPosition(chunkPosition, annotation); + matrix.transformPoint( + layerPosition, + chunkTransform.chunkToLayerTransform, + layerRank + 1, + chunkPosition, + layerRank, + ); + setLayerPosition(layer, chunkTransform, layerPosition); +}; + function visitTransformedAnnotationGeometry( annotation: Annotation, chunkTransform: ChunkTransformParameters, @@ -230,26 +251,6 @@ interface AnnotationLayerViewAttachedState { listOffset: number; } -const moveToAnnotation = ( - layer: UserLayer, - annotation: Annotation, - state: AnnotationLayerState, -) => { - const chunkTransform = state.chunkTransform.value as ChunkTransformParameters; - const { layerRank } = chunkTransform; - const chunkPosition = new Float32Array(layerRank); - const layerPosition = new Float32Array(layerRank); - getCenterPosition(chunkPosition, annotation); - matrix.transformPoint( - layerPosition, - chunkTransform.chunkToLayerTransform, - layerRank + 1, - chunkPosition, - layerRank, - ); - setLayerPosition(layer, chunkTransform, layerPosition); -}; - export class AnnotationLayerView extends Tab { private previousSelectedState: | { @@ -951,7 +952,6 @@ export class AnnotationLayerView extends Tab { event.stopPropagation(); this.layer.selectAnnotation(state, annotation.id, "toggle"); }); - element.addEventListener("action:pin-annotation", (event) => { event.stopPropagation(); this.layer.selectAnnotation(state, annotation.id, true); @@ -1055,7 +1055,9 @@ export class PlacePointTool extends PlaceAnnotationTool { relatedSegments: getSelectedAssociatedSegments(annotationLayer), point, type: AnnotationType.POINT, - properties: annotationLayer.source.properties.map((x) => x.default), + properties: annotationLayer.source.properties.value.map( + (x) => x.default, + ), }; const reference = annotationLayer.source.add( annotation, @@ -1207,7 +1209,7 @@ abstract class PlaceTwoCornerAnnotationTool extends TwoStepAnnotationTool { description: "", pointA: point, pointB: point, - properties: annotationLayer.source.properties.map((x) => x.default), + properties: annotationLayer.source.properties.value.map((x) => x.default), }; } @@ -1331,7 +1333,7 @@ class PlaceEllipsoidTool extends TwoStepAnnotationTool { segments: getSelectedAssociatedSegments(annotationLayer), center: point, radii: vec3.fromValues(0, 0, 0), - properties: annotationLayer.source.properties.map((x) => x.default), + properties: annotationLayer.source.properties.value.map((x) => x.default), }; } @@ -1750,7 +1752,7 @@ export function UserLayerWithAnnotationsMixin< new AnnotationPropertySerializer( rank, numGeometryBytes, - properties, + properties.value, ); const annotationIndex = state.annotationIndex!; const annotationCount = state.annotationCount!; @@ -1769,7 +1771,9 @@ export function UserLayerWithAnnotationsMixin< annotationIndex, annotationCount, isLittleEndian, - (annotation.properties = new Array(properties.length)), + (annotation.properties = new Array( + properties.value.length, + )), ); if (annotationLayer.source.hasNonSerializedProperties()) { statusText = "Loading..."; @@ -1867,7 +1871,10 @@ export function UserLayerWithAnnotationsMixin< positionGrid.appendChild(button); } - const { relationships, properties } = annotationLayer.source; + const { + relationships, + properties: { value: properties }, + } = annotationLayer.source; const sourceReadonly = annotationLayer.source.readonly; // Add the ID to the annotation details. @@ -1887,8 +1894,17 @@ export function UserLayerWithAnnotationsMixin< label.appendChild(valueElement); parent.appendChild(label); + const activeTags: string[] = []; + for (let i = 0, count = properties.length; i < count; ++i) { const property = properties[i]; + const value = annotation.properties[i]; + if (isAnnotationTagPropertySpec(property) && property.tag) { + if (value !== 0) { + activeTags.push(property.tag); + } + continue; + } const label = document.createElement("label"); label.classList.add("neuroglancer-annotation-property"); const idElement = document.createElement("span"); @@ -1901,7 +1917,6 @@ export function UserLayerWithAnnotationsMixin< if (description !== undefined) { label.title = description; } - const value = annotation.properties[i]; const valueElement = document.createElement("span"); valueElement.classList.add( "neuroglancer-annotation-property-value", @@ -1941,6 +1956,26 @@ export function UserLayerWithAnnotationsMixin< parent.appendChild(label); } + if (activeTags.length) { + const label = document.createElement("label"); + label.classList.add("neuroglancer-annotation-property"); + const idElement = document.createElement("span"); + idElement.classList.add( + "neuroglancer-annotation-property-label", + ); + idElement.textContent = "tags"; + label.appendChild(idElement); + const valueElement = document.createElement("span"); + valueElement.classList.add( + "neuroglancer-annotation-property-value", + ); + valueElement.textContent = activeTags + .map((x) => `#${x}`) + .join(" "); + label.appendChild(valueElement); + parent.appendChild(label); + } + const { relatedSegments } = annotation; for (let i = 0, count = relationships.length; i < count; ++i) { const related = diff --git a/src/ui/tool.ts b/src/ui/tool.ts index c422e430f..c33f1191f 100644 --- a/src/ui/tool.ts +++ b/src/ui/tool.ts @@ -212,6 +212,17 @@ export function registerTool( tools.set(type, getter); } +export function unregisterTool( + contextType: AnyConstructor, + type: string, +) { + const { prototype } = contextType; + const tools = toolsForPrototype.get(prototype); + if (tools) { + tools.delete(type); + } +} + export class SelectedLegacyTool extends RefCounted implements TrackableValueInterface @@ -318,15 +329,15 @@ export class GlobalToolBinder extends RefCounted { this.changed.dispatch(); } - activate(key: string): Borrowed | undefined { - const tool = this.get(key); + activate(key: string, tool?: Tool): Borrowed | undefined { + tool = tool || this.get(key); if (tool === undefined) { this.deactivate_(); return; } this.debounceDeactivate.cancel(); const activeTool = this.activeTool_; - if (tool === activeTool?.tool) { + if (tool.toJSON() === activeTool?.tool.toJSON()) { if (tool.toggle) { this.deactivate_(); } @@ -458,6 +469,19 @@ export class LocalToolBinder< return obj; } + deleteTool(key: string) { + const { globalBinder, bindings, jsonToKey } = this; + const existingTool = bindings.get(key); + if (existingTool) { + bindings.delete(key); + globalBinder.bindings.delete(key); + jsonToKey.delete(JSON.stringify(existingTool.toJSON())); + globalBinder.destroyTool(existingTool); + globalBinder.changed.dispatch(); + this.changed.dispatch(); + } + } + clear() { const { globalBinder, bindings } = this; if (bindings.size !== 0) {